- Services now use db.flush() instead of db.commit() for database operations - API endpoints handle transaction commit after service calls - Remove db.rollback() from services (let exception handlers manage this) - Ensures consistent transaction boundaries at API layer This pattern gives API endpoints full control over when to commit, allowing for better error handling and potential multi-operation transactions. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
467 lines
13 KiB
Python
467 lines
13 KiB
Python
"""
|
|
Code Quality API Endpoints
|
|
RESTful API for architecture validation and violation management
|
|
"""
|
|
|
|
from datetime import datetime
|
|
|
|
from fastapi import APIRouter, Depends, Query
|
|
from pydantic import BaseModel, Field
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.api.deps import get_current_admin_api
|
|
from app.core.database import get_db
|
|
from app.exceptions import ViolationNotFoundException
|
|
from app.services.code_quality_service import code_quality_service
|
|
from models.database.user import User
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
# Pydantic Models for API
|
|
|
|
|
|
class ScanResponse(BaseModel):
|
|
"""Response model for a scan"""
|
|
|
|
id: int
|
|
timestamp: str
|
|
total_files: int
|
|
total_violations: int
|
|
errors: int
|
|
warnings: int
|
|
duration_seconds: float
|
|
triggered_by: str
|
|
git_commit_hash: str | None
|
|
|
|
class Config:
|
|
from_attributes = True
|
|
|
|
|
|
class ViolationResponse(BaseModel):
|
|
"""Response model for a violation"""
|
|
|
|
id: int
|
|
scan_id: int
|
|
rule_id: str
|
|
rule_name: str
|
|
severity: str
|
|
file_path: str
|
|
line_number: int
|
|
message: str
|
|
context: str | None
|
|
suggestion: str | None
|
|
status: str
|
|
assigned_to: int | None
|
|
resolved_at: str | None
|
|
resolved_by: int | None
|
|
resolution_note: str | None
|
|
created_at: str
|
|
|
|
class Config:
|
|
from_attributes = True
|
|
|
|
|
|
class ViolationListResponse(BaseModel):
|
|
"""Response model for paginated violations list"""
|
|
|
|
violations: list[ViolationResponse]
|
|
total: int
|
|
page: int
|
|
page_size: int
|
|
total_pages: int
|
|
|
|
|
|
class ViolationDetailResponse(ViolationResponse):
|
|
"""Response model for single violation with relationships"""
|
|
|
|
assignments: list = []
|
|
comments: list = []
|
|
|
|
|
|
class AssignViolationRequest(BaseModel):
|
|
"""Request model for assigning a violation"""
|
|
|
|
user_id: int = Field(..., description="User ID to assign to")
|
|
due_date: datetime | None = Field(None, description="Due date for resolution")
|
|
priority: str = Field(
|
|
"medium", description="Priority level (low, medium, high, critical)"
|
|
)
|
|
|
|
|
|
class ResolveViolationRequest(BaseModel):
|
|
"""Request model for resolving a violation"""
|
|
|
|
resolution_note: str = Field(..., description="Note about the resolution")
|
|
|
|
|
|
class IgnoreViolationRequest(BaseModel):
|
|
"""Request model for ignoring a violation"""
|
|
|
|
reason: str = Field(..., description="Reason for ignoring")
|
|
|
|
|
|
class AddCommentRequest(BaseModel):
|
|
"""Request model for adding a comment"""
|
|
|
|
comment: str = Field(..., min_length=1, description="Comment text")
|
|
|
|
|
|
class DashboardStatsResponse(BaseModel):
|
|
"""Response model for dashboard statistics"""
|
|
|
|
total_violations: int
|
|
errors: int
|
|
warnings: int
|
|
open: int
|
|
assigned: int
|
|
resolved: int
|
|
ignored: int
|
|
technical_debt_score: int
|
|
trend: list
|
|
by_severity: dict
|
|
by_rule: dict
|
|
by_module: dict
|
|
top_files: list
|
|
last_scan: str | None = None
|
|
|
|
|
|
# API Endpoints
|
|
|
|
|
|
@router.post("/scan", response_model=ScanResponse)
|
|
async def trigger_scan(
|
|
db: Session = Depends(get_db), current_user: User = Depends(get_current_admin_api)
|
|
):
|
|
"""
|
|
Trigger a new architecture scan
|
|
|
|
Requires authentication. Runs the validator script and stores results.
|
|
Domain exceptions (ScanTimeoutException, ScanParseException) bubble up to global handler.
|
|
"""
|
|
scan = code_quality_service.run_scan(
|
|
db, triggered_by=f"manual:{current_user.username}"
|
|
)
|
|
db.commit()
|
|
|
|
return ScanResponse(
|
|
id=scan.id,
|
|
timestamp=scan.timestamp.isoformat(),
|
|
total_files=scan.total_files,
|
|
total_violations=scan.total_violations,
|
|
errors=scan.errors,
|
|
warnings=scan.warnings,
|
|
duration_seconds=scan.duration_seconds,
|
|
triggered_by=scan.triggered_by,
|
|
git_commit_hash=scan.git_commit_hash,
|
|
)
|
|
|
|
|
|
@router.get("/scans", response_model=list[ScanResponse])
|
|
async def list_scans(
|
|
limit: int = Query(30, ge=1, le=100, description="Number of scans to return"),
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(get_current_admin_api),
|
|
):
|
|
"""
|
|
Get scan history
|
|
|
|
Returns recent scans for trend analysis.
|
|
"""
|
|
scans = code_quality_service.get_scan_history(db, limit=limit)
|
|
|
|
return [
|
|
ScanResponse(
|
|
id=scan.id,
|
|
timestamp=scan.timestamp.isoformat(),
|
|
total_files=scan.total_files,
|
|
total_violations=scan.total_violations,
|
|
errors=scan.errors,
|
|
warnings=scan.warnings,
|
|
duration_seconds=scan.duration_seconds,
|
|
triggered_by=scan.triggered_by,
|
|
git_commit_hash=scan.git_commit_hash,
|
|
)
|
|
for scan in scans
|
|
]
|
|
|
|
|
|
@router.get("/violations", response_model=ViolationListResponse)
|
|
async def list_violations(
|
|
scan_id: int | None = Query(
|
|
None, description="Filter by scan ID (defaults to latest)"
|
|
),
|
|
severity: str | None = Query(
|
|
None, description="Filter by severity (error, warning)"
|
|
),
|
|
status: str | None = Query(
|
|
None, description="Filter by status (open, assigned, resolved, ignored)"
|
|
),
|
|
rule_id: str | None = Query(None, description="Filter by rule ID"),
|
|
file_path: str | None = Query(
|
|
None, description="Filter by file path (partial match)"
|
|
),
|
|
page: int = Query(1, ge=1, description="Page number"),
|
|
page_size: int = Query(50, ge=1, le=200, description="Items per page"),
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(get_current_admin_api),
|
|
):
|
|
"""
|
|
Get violations with filtering and pagination
|
|
|
|
Returns violations from latest scan by default.
|
|
"""
|
|
offset = (page - 1) * page_size
|
|
|
|
violations, total = code_quality_service.get_violations(
|
|
db,
|
|
scan_id=scan_id,
|
|
severity=severity,
|
|
status=status,
|
|
rule_id=rule_id,
|
|
file_path=file_path,
|
|
limit=page_size,
|
|
offset=offset,
|
|
)
|
|
|
|
total_pages = (total + page_size - 1) // page_size
|
|
|
|
return ViolationListResponse(
|
|
violations=[
|
|
ViolationResponse(
|
|
id=v.id,
|
|
scan_id=v.scan_id,
|
|
rule_id=v.rule_id,
|
|
rule_name=v.rule_name,
|
|
severity=v.severity,
|
|
file_path=v.file_path,
|
|
line_number=v.line_number,
|
|
message=v.message,
|
|
context=v.context,
|
|
suggestion=v.suggestion,
|
|
status=v.status,
|
|
assigned_to=v.assigned_to,
|
|
resolved_at=v.resolved_at.isoformat() if v.resolved_at else None,
|
|
resolved_by=v.resolved_by,
|
|
resolution_note=v.resolution_note,
|
|
created_at=v.created_at.isoformat(),
|
|
)
|
|
for v in violations
|
|
],
|
|
total=total,
|
|
page=page,
|
|
page_size=page_size,
|
|
total_pages=total_pages,
|
|
)
|
|
|
|
|
|
@router.get("/violations/{violation_id}", response_model=ViolationDetailResponse)
|
|
async def get_violation(
|
|
violation_id: int,
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(get_current_admin_api),
|
|
):
|
|
"""
|
|
Get single violation with details
|
|
|
|
Includes assignments and comments.
|
|
"""
|
|
violation = code_quality_service.get_violation_by_id(db, violation_id)
|
|
|
|
if not violation:
|
|
raise ViolationNotFoundException(violation_id)
|
|
|
|
# Format assignments
|
|
assignments = [
|
|
{
|
|
"id": a.id,
|
|
"user_id": a.user_id,
|
|
"assigned_at": a.assigned_at.isoformat(),
|
|
"assigned_by": a.assigned_by,
|
|
"due_date": a.due_date.isoformat() if a.due_date else None,
|
|
"priority": a.priority,
|
|
}
|
|
for a in violation.assignments
|
|
]
|
|
|
|
# Format comments
|
|
comments = [
|
|
{
|
|
"id": c.id,
|
|
"user_id": c.user_id,
|
|
"comment": c.comment,
|
|
"created_at": c.created_at.isoformat(),
|
|
}
|
|
for c in violation.comments
|
|
]
|
|
|
|
return ViolationDetailResponse(
|
|
id=violation.id,
|
|
scan_id=violation.scan_id,
|
|
rule_id=violation.rule_id,
|
|
rule_name=violation.rule_name,
|
|
severity=violation.severity,
|
|
file_path=violation.file_path,
|
|
line_number=violation.line_number,
|
|
message=violation.message,
|
|
context=violation.context,
|
|
suggestion=violation.suggestion,
|
|
status=violation.status,
|
|
assigned_to=violation.assigned_to,
|
|
resolved_at=(
|
|
violation.resolved_at.isoformat() if violation.resolved_at else None
|
|
),
|
|
resolved_by=violation.resolved_by,
|
|
resolution_note=violation.resolution_note,
|
|
created_at=violation.created_at.isoformat(),
|
|
assignments=assignments,
|
|
comments=comments,
|
|
)
|
|
|
|
|
|
@router.post("/violations/{violation_id}/assign")
|
|
async def assign_violation(
|
|
violation_id: int,
|
|
request: AssignViolationRequest,
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(get_current_admin_api),
|
|
):
|
|
"""
|
|
Assign violation to a developer
|
|
|
|
Updates violation status to 'assigned'.
|
|
"""
|
|
assignment = code_quality_service.assign_violation(
|
|
db,
|
|
violation_id=violation_id,
|
|
user_id=request.user_id,
|
|
assigned_by=current_user.id,
|
|
due_date=request.due_date,
|
|
priority=request.priority,
|
|
)
|
|
db.commit()
|
|
|
|
return {
|
|
"id": assignment.id,
|
|
"violation_id": assignment.violation_id,
|
|
"user_id": assignment.user_id,
|
|
"assigned_at": assignment.assigned_at.isoformat(),
|
|
"assigned_by": assignment.assigned_by,
|
|
"due_date": (
|
|
assignment.due_date.isoformat() if assignment.due_date else None
|
|
),
|
|
"priority": assignment.priority,
|
|
}
|
|
|
|
|
|
@router.post("/violations/{violation_id}/resolve")
|
|
async def resolve_violation(
|
|
violation_id: int,
|
|
request: ResolveViolationRequest,
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(get_current_admin_api),
|
|
):
|
|
"""
|
|
Mark violation as resolved
|
|
|
|
Records resolution timestamp and user.
|
|
ViolationNotFoundException bubbles up if violation doesn't exist.
|
|
"""
|
|
violation = code_quality_service.resolve_violation(
|
|
db,
|
|
violation_id=violation_id,
|
|
resolved_by=current_user.id,
|
|
resolution_note=request.resolution_note,
|
|
)
|
|
db.commit()
|
|
|
|
return {
|
|
"id": violation.id,
|
|
"status": violation.status,
|
|
"resolved_at": (
|
|
violation.resolved_at.isoformat() if violation.resolved_at else None
|
|
),
|
|
"resolved_by": violation.resolved_by,
|
|
"resolution_note": violation.resolution_note,
|
|
}
|
|
|
|
|
|
@router.post("/violations/{violation_id}/ignore")
|
|
async def ignore_violation(
|
|
violation_id: int,
|
|
request: IgnoreViolationRequest,
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(get_current_admin_api),
|
|
):
|
|
"""
|
|
Mark violation as ignored (won't fix)
|
|
|
|
Records reason for ignoring.
|
|
ViolationNotFoundException bubbles up if violation doesn't exist.
|
|
"""
|
|
violation = code_quality_service.ignore_violation(
|
|
db,
|
|
violation_id=violation_id,
|
|
ignored_by=current_user.id,
|
|
reason=request.reason,
|
|
)
|
|
db.commit()
|
|
|
|
return {
|
|
"id": violation.id,
|
|
"status": violation.status,
|
|
"resolved_at": (
|
|
violation.resolved_at.isoformat() if violation.resolved_at else None
|
|
),
|
|
"resolved_by": violation.resolved_by,
|
|
"resolution_note": violation.resolution_note,
|
|
}
|
|
|
|
|
|
@router.post("/violations/{violation_id}/comments")
|
|
async def add_comment(
|
|
violation_id: int,
|
|
request: AddCommentRequest,
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(get_current_admin_api),
|
|
):
|
|
"""
|
|
Add comment to violation
|
|
|
|
For team collaboration and discussion.
|
|
"""
|
|
comment = code_quality_service.add_comment(
|
|
db,
|
|
violation_id=violation_id,
|
|
user_id=current_user.id,
|
|
comment=request.comment,
|
|
)
|
|
db.commit()
|
|
|
|
return {
|
|
"id": comment.id,
|
|
"violation_id": comment.violation_id,
|
|
"user_id": comment.user_id,
|
|
"comment": comment.comment,
|
|
"created_at": comment.created_at.isoformat(),
|
|
}
|
|
|
|
|
|
@router.get("/stats", response_model=DashboardStatsResponse)
|
|
async def get_dashboard_stats(
|
|
db: Session = Depends(get_db), current_user: User = Depends(get_current_admin_api)
|
|
):
|
|
"""
|
|
Get dashboard statistics
|
|
|
|
Returns comprehensive stats for the dashboard including:
|
|
- Total counts by severity and status
|
|
- Technical debt score
|
|
- Trend data (last 7 scans)
|
|
- Top violating files
|
|
- Violations by rule and module
|
|
"""
|
|
stats = code_quality_service.get_dashboard_stats(db)
|
|
|
|
return DashboardStatsResponse(**stats)
|