From 9db0da25ec4c05e29f8c09c35fd5f815807d50b1 Mon Sep 17 00:00:00 2001 From: Samir Boulahtit Date: Fri, 28 Nov 2025 09:40:14 +0100 Subject: [PATCH] feat: implement code quality dashboard with architecture violation tracking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement comprehensive code quality dashboard (Phase 2-4) to track and manage architecture violations found by the validation script. Backend Implementation: - Add JSON output support to validate_architecture.py script - Create CodeQualityService with scan management, violation tracking, and statistics - Implement REST API endpoints for code quality management: * POST /admin/code-quality/scan - trigger new architecture scan * GET /admin/code-quality/scans - list scan history * GET /admin/code-quality/violations - list violations with filtering/pagination * GET /admin/code-quality/violations/{id} - get violation details * POST /admin/code-quality/violations/{id}/assign - assign to developer * POST /admin/code-quality/violations/{id}/resolve - mark as resolved * POST /admin/code-quality/violations/{id}/ignore - mark as ignored * POST /admin/code-quality/violations/{id}/comments - add comments * GET /admin/code-quality/stats - dashboard statistics - Fix architecture_scan model imports to use app.core.database Frontend Implementation: - Create code quality dashboard page (code-quality-dashboard.html) * Summary cards for total violations, errors, warnings, health score * Status breakdown (open, assigned, resolved, ignored) * Trend visualization for last 7 scans * Top violating files list * Violations by rule and module * Quick action links - Create violations list page (code-quality-violations.html) * Filterable table by severity, status, rule ID, file path * Pagination support * Violation detail view links - Add Alpine.js components (code-quality-dashboard.js, code-quality-violations.js) * Dashboard state management and scan triggering * Violations list with filtering and pagination * API integration with authentication - Add "Code Quality" navigation link in admin sidebar (Developer Tools section) Routes: - GET /admin/code-quality - dashboard page - GET /admin/code-quality/violations - violations list - GET /admin/code-quality/violations/{id} - violation details Features: - Real-time scan execution from UI - Technical debt score calculation (0-100 scale) - Violation workflow: open → assigned → resolved/ignored - Trend tracking across multiple scans - File and module-level insights - Assignment system with priorities and due dates - Collaborative comments on violations 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- app/api/v1/admin/__init__.py | 12 +- app/api/v1/admin/code_quality.py | 448 +++++++++++++++ app/models/architecture_scan.py | 2 +- app/routes/admin_pages.py | 66 +++ app/services/code_quality_service.py | 513 ++++++++++++++++++ .../admin/code-quality-dashboard.html | 300 ++++++++++ .../admin/code-quality-violations.html | 227 ++++++++ app/templates/admin/partials/sidebar.html | 22 + scripts/validate_architecture.py | 43 +- static/admin/js/code-quality-dashboard.js | 106 ++++ static/admin/js/code-quality-violations.js | 119 ++++ 11 files changed, 1854 insertions(+), 4 deletions(-) create mode 100644 app/api/v1/admin/code_quality.py create mode 100644 app/services/code_quality_service.py create mode 100644 app/templates/admin/code-quality-dashboard.html create mode 100644 app/templates/admin/code-quality-violations.html create mode 100644 static/admin/js/code-quality-dashboard.js create mode 100644 static/admin/js/code-quality-violations.js diff --git a/app/api/v1/admin/__init__.py b/app/api/v1/admin/__init__.py index 99d2d487..f2e5ca10 100644 --- a/app/api/v1/admin/__init__.py +++ b/app/api/v1/admin/__init__.py @@ -13,6 +13,7 @@ This module combines all admin-related JSON API endpoints: - Audit logging - Platform settings - Notifications and alerts +- Code quality and architecture validation IMPORTANT: - This router is for JSON API endpoints only @@ -35,7 +36,8 @@ from . import ( audit, settings, notifications, - content_pages + content_pages, + code_quality ) # Create admin router @@ -107,5 +109,13 @@ router.include_router(settings.router, tags=["admin-settings"]) # Include notifications and alerts endpoints router.include_router(notifications.router, tags=["admin-notifications"]) + +# ============================================================================ +# Code Quality & Architecture +# ============================================================================ + +# Include code quality and architecture validation endpoints +router.include_router(code_quality.router, prefix="/code-quality", tags=["admin-code-quality"]) + # Export the router __all__ = ["router"] diff --git a/app/api/v1/admin/code_quality.py b/app/api/v1/admin/code_quality.py new file mode 100644 index 00000000..de3e5674 --- /dev/null +++ b/app/api/v1/admin/code_quality.py @@ -0,0 +1,448 @@ +""" +Code Quality API Endpoints +RESTful API for architecture validation and violation management +""" + +from typing import Optional +from datetime import datetime +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.orm import Session +from pydantic import BaseModel, Field + +from app.core.database import get_db +from app.services.code_quality_service import code_quality_service +from app.api.deps import get_current_admin_api +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: Optional[str] + + 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: Optional[str] + suggestion: Optional[str] + status: str + assigned_to: Optional[int] + resolved_at: Optional[str] + resolved_by: Optional[int] + resolution_note: Optional[str] + 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: Optional[datetime] = 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: Optional[str] + + +# 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. + """ + try: + scan = code_quality_service.run_scan(db, triggered_by=f"manual:{current_user.username}") + + 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 + ) + except Exception as e: + raise HTTPException(status_code=500, detail=f"Scan failed: {str(e)}") + + +@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: Optional[int] = Query(None, description="Filter by scan ID (defaults to latest)"), + severity: Optional[str] = Query(None, description="Filter by severity (error, warning)"), + status: Optional[str] = Query(None, description="Filter by status (open, assigned, resolved, ignored)"), + rule_id: Optional[str] = Query(None, description="Filter by rule ID"), + file_path: Optional[str] = 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 HTTPException(status_code=404, detail="Violation not found") + + # 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'. + """ + try: + 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 + ) + + 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 + } + except Exception as e: + raise HTTPException(status_code=400, detail=str(e)) + + +@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. + """ + try: + violation = code_quality_service.resolve_violation( + db, + violation_id=violation_id, + resolved_by=current_user.id, + resolution_note=request.resolution_note + ) + + 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 + } + except ValueError as e: + raise HTTPException(status_code=404, detail=str(e)) + except Exception as e: + raise HTTPException(status_code=400, detail=str(e)) + + +@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. + """ + try: + violation = code_quality_service.ignore_violation( + db, + violation_id=violation_id, + ignored_by=current_user.id, + reason=request.reason + ) + + 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 + } + except ValueError as e: + raise HTTPException(status_code=404, detail=str(e)) + except Exception as e: + raise HTTPException(status_code=400, detail=str(e)) + + +@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. + """ + try: + comment = code_quality_service.add_comment( + db, + violation_id=violation_id, + user_id=current_user.id, + comment=request.comment + ) + + return { + 'id': comment.id, + 'violation_id': comment.violation_id, + 'user_id': comment.user_id, + 'comment': comment.comment, + 'created_at': comment.created_at.isoformat() + } + except Exception as e: + raise HTTPException(status_code=400, detail=str(e)) + + +@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) diff --git a/app/models/architecture_scan.py b/app/models/architecture_scan.py index 033250a3..39b861ec 100644 --- a/app/models/architecture_scan.py +++ b/app/models/architecture_scan.py @@ -6,7 +6,7 @@ Database models for tracking code quality scans and violations from sqlalchemy import Column, Integer, String, Float, DateTime, Text, Boolean, ForeignKey, JSON from sqlalchemy.orm import relationship from sqlalchemy.sql import func -from app.database import Base +from app.core.database import Base class ArchitectureScan(Base): diff --git a/app/routes/admin_pages.py b/app/routes/admin_pages.py index 50544baf..49b10873 100644 --- a/app/routes/admin_pages.py +++ b/app/routes/admin_pages.py @@ -25,6 +25,9 @@ Routes: - GET /content-pages → Content pages list (auth required) - GET /content-pages/create → Create content page (auth required) - GET /content-pages/{page_id}/edit → Edit content page (auth required) +- GET /code-quality → Code quality dashboard (auth required) +- GET /code-quality/violations → Violations list (auth required) +- GET /code-quality/violations/{violation_id} → Violation details (auth required) """ from fastapi import APIRouter, Request, Depends, Path @@ -490,3 +493,66 @@ async def admin_test_vendors_users_migration( "user": current_user, } ) + + +# ============================================================================ +# CODE QUALITY & ARCHITECTURE ROUTES +# ============================================================================ + +@router.get("/code-quality", response_class=HTMLResponse, include_in_schema=False) +async def admin_code_quality_dashboard( + request: Request, + current_user: User = Depends(get_current_admin_from_cookie_or_header), + db: Session = Depends(get_db) +): + """ + Render code quality dashboard. + Shows architecture violations, trends, and technical debt score. + """ + return templates.TemplateResponse( + "admin/code-quality-dashboard.html", + { + "request": request, + "user": current_user, + } + ) + + +@router.get("/code-quality/violations", response_class=HTMLResponse, include_in_schema=False) +async def admin_code_quality_violations( + request: Request, + current_user: User = Depends(get_current_admin_from_cookie_or_header), + db: Session = Depends(get_db) +): + """ + Render violations list page. + Shows all violations with filtering and sorting options. + """ + return templates.TemplateResponse( + "admin/code-quality-violations.html", + { + "request": request, + "user": current_user, + } + ) + + +@router.get("/code-quality/violations/{violation_id}", response_class=HTMLResponse, include_in_schema=False) +async def admin_code_quality_violation_detail( + request: Request, + violation_id: int = Path(..., description="Violation ID"), + current_user: User = Depends(get_current_admin_from_cookie_or_header), + db: Session = Depends(get_db) +): + """ + Render violation detail page. + Shows violation details, code context, assignments, and comments. + """ + return templates.TemplateResponse( + "admin/code-quality-violation-detail.html", + { + "request": request, + "user": current_user, + "violation_id": violation_id, + } + ) diff --git a/app/services/code_quality_service.py b/app/services/code_quality_service.py new file mode 100644 index 00000000..d129ff2b --- /dev/null +++ b/app/services/code_quality_service.py @@ -0,0 +1,513 @@ +""" +Code Quality Service +Business logic for managing architecture scans and violations +""" + +import subprocess +import json +import logging +from datetime import datetime +from typing import List, Tuple, Optional, Dict +from pathlib import Path +from sqlalchemy.orm import Session +from sqlalchemy import func, desc + +from app.models.architecture_scan import ( + ArchitectureScan, + ArchitectureViolation, + ArchitectureRule, + ViolationAssignment, + ViolationComment +) + +logger = logging.getLogger(__name__) + + +class CodeQualityService: + """Service for managing code quality scans and violations""" + + def run_scan(self, db: Session, triggered_by: str = 'manual') -> ArchitectureScan: + """ + Run architecture validator and store results in database + + Args: + db: Database session + triggered_by: Who/what triggered the scan ('manual', 'scheduled', 'ci/cd') + + Returns: + ArchitectureScan object with results + + Raises: + Exception: If validator script fails + """ + logger.info(f"Starting architecture scan (triggered by: {triggered_by})") + + # Get git commit hash + git_commit = self._get_git_commit_hash() + + # Run validator with JSON output + start_time = datetime.now() + try: + result = subprocess.run( + ['python', 'scripts/validate_architecture.py', '--json'], + capture_output=True, + text=True, + timeout=300 # 5 minute timeout + ) + except subprocess.TimeoutExpired: + logger.error("Architecture scan timed out after 5 minutes") + raise Exception("Scan timed out") + + duration = (datetime.now() - start_time).total_seconds() + + # Parse JSON output (get only the JSON part, skip progress messages) + try: + # Find the JSON part in stdout + lines = result.stdout.strip().split('\n') + json_start = -1 + for i, line in enumerate(lines): + if line.strip().startswith('{'): + json_start = i + break + + if json_start == -1: + raise ValueError("No JSON output found") + + json_output = '\n'.join(lines[json_start:]) + data = json.loads(json_output) + except (json.JSONDecodeError, ValueError) as e: + logger.error(f"Failed to parse validator output: {e}") + logger.error(f"Stdout: {result.stdout}") + logger.error(f"Stderr: {result.stderr}") + raise Exception(f"Failed to parse scan results: {e}") + + # Create scan record + scan = ArchitectureScan( + timestamp=datetime.now(), + total_files=data.get('files_checked', 0), + total_violations=data.get('total_violations', 0), + errors=data.get('errors', 0), + warnings=data.get('warnings', 0), + duration_seconds=duration, + triggered_by=triggered_by, + git_commit_hash=git_commit + ) + db.add(scan) + db.flush() # Get scan.id + + # Create violation records + violations_data = data.get('violations', []) + logger.info(f"Creating {len(violations_data)} violation records") + + for v in violations_data: + violation = ArchitectureViolation( + scan_id=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.get('context', ''), + suggestion=v.get('suggestion', ''), + status='open' + ) + db.add(violation) + + db.commit() + db.refresh(scan) + + logger.info(f"Scan completed: {scan.total_violations} violations found") + return scan + + def get_latest_scan(self, db: Session) -> Optional[ArchitectureScan]: + """Get the most recent scan""" + return db.query(ArchitectureScan).order_by(desc(ArchitectureScan.timestamp)).first() + + def get_scan_by_id(self, db: Session, scan_id: int) -> Optional[ArchitectureScan]: + """Get scan by ID""" + return db.query(ArchitectureScan).filter(ArchitectureScan.id == scan_id).first() + + def get_scan_history(self, db: Session, limit: int = 30) -> List[ArchitectureScan]: + """ + Get scan history for trend graphs + + Args: + db: Database session + limit: Maximum number of scans to return + + Returns: + List of ArchitectureScan objects, newest first + """ + return db.query(ArchitectureScan)\ + .order_by(desc(ArchitectureScan.timestamp))\ + .limit(limit)\ + .all() + + def get_violations( + self, + db: Session, + scan_id: int = None, + severity: str = None, + status: str = None, + rule_id: str = None, + file_path: str = None, + limit: int = 100, + offset: int = 0 + ) -> Tuple[List[ArchitectureViolation], int]: + """ + Get violations with filtering and pagination + + Args: + db: Database session + scan_id: Filter by scan ID (if None, use latest scan) + severity: Filter by severity ('error', 'warning') + status: Filter by status ('open', 'assigned', 'resolved', etc.) + rule_id: Filter by rule ID + file_path: Filter by file path (partial match) + limit: Page size + offset: Page offset + + Returns: + Tuple of (violations list, total count) + """ + # If no scan_id specified, use latest scan + if scan_id is None: + latest_scan = self.get_latest_scan(db) + if not latest_scan: + return [], 0 + scan_id = latest_scan.id + + # Build query + query = db.query(ArchitectureViolation).filter( + ArchitectureViolation.scan_id == scan_id + ) + + # Apply filters + if severity: + query = query.filter(ArchitectureViolation.severity == severity) + + if status: + query = query.filter(ArchitectureViolation.status == status) + + if rule_id: + query = query.filter(ArchitectureViolation.rule_id == rule_id) + + if file_path: + query = query.filter(ArchitectureViolation.file_path.like(f'%{file_path}%')) + + # Get total count + total = query.count() + + # Get page of results + violations = query.order_by( + ArchitectureViolation.severity.desc(), + ArchitectureViolation.file_path + ).limit(limit).offset(offset).all() + + return violations, total + + def get_violation_by_id(self, db: Session, violation_id: int) -> Optional[ArchitectureViolation]: + """Get single violation with details""" + return db.query(ArchitectureViolation).filter( + ArchitectureViolation.id == violation_id + ).first() + + def assign_violation( + self, + db: Session, + violation_id: int, + user_id: int, + assigned_by: int, + due_date: datetime = None, + priority: str = 'medium' + ) -> ViolationAssignment: + """ + Assign violation to a developer + + Args: + db: Database session + violation_id: Violation ID + user_id: User to assign to + assigned_by: User who is assigning + due_date: Due date (optional) + priority: Priority level ('low', 'medium', 'high', 'critical') + + Returns: + ViolationAssignment object + """ + # Update violation status + violation = self.get_violation_by_id(db, violation_id) + if violation: + violation.status = 'assigned' + violation.assigned_to = user_id + + # Create assignment record + assignment = ViolationAssignment( + violation_id=violation_id, + user_id=user_id, + assigned_by=assigned_by, + due_date=due_date, + priority=priority + ) + db.add(assignment) + db.commit() + + logger.info(f"Violation {violation_id} assigned to user {user_id}") + return assignment + + def resolve_violation( + self, + db: Session, + violation_id: int, + resolved_by: int, + resolution_note: str + ) -> ArchitectureViolation: + """ + Mark violation as resolved + + Args: + db: Database session + violation_id: Violation ID + resolved_by: User who resolved it + resolution_note: Note about resolution + + Returns: + Updated ArchitectureViolation object + """ + violation = self.get_violation_by_id(db, violation_id) + if not violation: + raise ValueError(f"Violation {violation_id} not found") + + violation.status = 'resolved' + violation.resolved_at = datetime.now() + violation.resolved_by = resolved_by + violation.resolution_note = resolution_note + + db.commit() + logger.info(f"Violation {violation_id} resolved by user {resolved_by}") + return violation + + def ignore_violation( + self, + db: Session, + violation_id: int, + ignored_by: int, + reason: str + ) -> ArchitectureViolation: + """ + Mark violation as ignored/won't fix + + Args: + db: Database session + violation_id: Violation ID + ignored_by: User who ignored it + reason: Reason for ignoring + + Returns: + Updated ArchitectureViolation object + """ + violation = self.get_violation_by_id(db, violation_id) + if not violation: + raise ValueError(f"Violation {violation_id} not found") + + violation.status = 'ignored' + violation.resolved_at = datetime.now() + violation.resolved_by = ignored_by + violation.resolution_note = f"Ignored: {reason}" + + db.commit() + logger.info(f"Violation {violation_id} ignored by user {ignored_by}") + return violation + + def add_comment( + self, + db: Session, + violation_id: int, + user_id: int, + comment: str + ) -> ViolationComment: + """ + Add comment to violation + + Args: + db: Database session + violation_id: Violation ID + user_id: User posting comment + comment: Comment text + + Returns: + ViolationComment object + """ + comment_obj = ViolationComment( + violation_id=violation_id, + user_id=user_id, + comment=comment + ) + db.add(comment_obj) + db.commit() + + logger.info(f"Comment added to violation {violation_id} by user {user_id}") + return comment_obj + + def get_dashboard_stats(self, db: Session) -> Dict: + """ + Get statistics for dashboard + + Returns: + Dictionary with various statistics + """ + latest_scan = self.get_latest_scan(db) + if not latest_scan: + return { + 'total_violations': 0, + 'errors': 0, + 'warnings': 0, + 'open': 0, + 'assigned': 0, + 'resolved': 0, + 'ignored': 0, + 'technical_debt_score': 100, + 'trend': [], + 'by_severity': {}, + 'by_rule': {}, + 'by_module': {}, + 'top_files': [] + } + + # Get violation counts by status + status_counts = db.query( + ArchitectureViolation.status, + func.count(ArchitectureViolation.id) + ).filter( + ArchitectureViolation.scan_id == latest_scan.id + ).group_by(ArchitectureViolation.status).all() + + status_dict = {status: count for status, count in status_counts} + + # Get violations by severity + severity_counts = db.query( + ArchitectureViolation.severity, + func.count(ArchitectureViolation.id) + ).filter( + ArchitectureViolation.scan_id == latest_scan.id + ).group_by(ArchitectureViolation.severity).all() + + by_severity = {sev: count for sev, count in severity_counts} + + # Get violations by rule + rule_counts = db.query( + ArchitectureViolation.rule_id, + func.count(ArchitectureViolation.id) + ).filter( + ArchitectureViolation.scan_id == latest_scan.id + ).group_by(ArchitectureViolation.rule_id).all() + + by_rule = {rule: count for rule, count in sorted(rule_counts, key=lambda x: x[1], reverse=True)[:10]} + + # Get top violating files + file_counts = db.query( + ArchitectureViolation.file_path, + func.count(ArchitectureViolation.id).label('count') + ).filter( + ArchitectureViolation.scan_id == latest_scan.id + ).group_by(ArchitectureViolation.file_path)\ + .order_by(desc('count'))\ + .limit(10).all() + + top_files = [{'file': file, 'count': count} for file, count in file_counts] + + # Get violations by module (extract module from file path) + by_module = {} + violations = db.query(ArchitectureViolation.file_path).filter( + ArchitectureViolation.scan_id == latest_scan.id + ).all() + + for v in violations: + path_parts = v.file_path.split('/') + if len(path_parts) >= 2: + module = '/'.join(path_parts[:2]) # e.g., 'app/api' + else: + module = path_parts[0] + by_module[module] = by_module.get(module, 0) + 1 + + # Sort by count and take top 10 + by_module = dict(sorted(by_module.items(), key=lambda x: x[1], reverse=True)[:10]) + + # Calculate technical debt score + tech_debt_score = self.calculate_technical_debt_score(db, latest_scan.id) + + # Get trend (last 7 scans) + trend_scans = self.get_scan_history(db, limit=7) + trend = [ + { + 'timestamp': scan.timestamp.isoformat(), + 'violations': scan.total_violations, + 'errors': scan.errors, + 'warnings': scan.warnings + } + for scan in reversed(trend_scans) # Oldest first for chart + ] + + return { + 'total_violations': latest_scan.total_violations, + 'errors': latest_scan.errors, + 'warnings': latest_scan.warnings, + 'open': status_dict.get('open', 0), + 'assigned': status_dict.get('assigned', 0), + 'resolved': status_dict.get('resolved', 0), + 'ignored': status_dict.get('ignored', 0), + 'technical_debt_score': tech_debt_score, + 'trend': trend, + 'by_severity': by_severity, + 'by_rule': by_rule, + 'by_module': by_module, + 'top_files': top_files, + 'last_scan': latest_scan.timestamp.isoformat() if latest_scan else None + } + + def calculate_technical_debt_score(self, db: Session, scan_id: int = None) -> int: + """ + Calculate technical debt score (0-100) + + Formula: 100 - (errors * 0.5 + warnings * 0.05) + Capped at 0 minimum + + Args: + db: Database session + scan_id: Scan ID (if None, use latest) + + Returns: + Score from 0-100 + """ + if scan_id is None: + latest_scan = self.get_latest_scan(db) + if not latest_scan: + return 100 + scan_id = latest_scan.id + + scan = self.get_scan_by_id(db, scan_id) + if not scan: + return 100 + + score = 100 - (scan.errors * 0.5 + scan.warnings * 0.05) + return max(0, min(100, int(score))) # Clamp to 0-100 + + def _get_git_commit_hash(self) -> Optional[str]: + """Get current git commit hash""" + try: + result = subprocess.run( + ['git', 'rev-parse', 'HEAD'], + capture_output=True, + text=True, + timeout=5 + ) + if result.returncode == 0: + return result.stdout.strip()[:40] + except Exception: + pass + return None + + +# Singleton instance +code_quality_service = CodeQualityService() diff --git a/app/templates/admin/code-quality-dashboard.html b/app/templates/admin/code-quality-dashboard.html new file mode 100644 index 00000000..9e8bde91 --- /dev/null +++ b/app/templates/admin/code-quality-dashboard.html @@ -0,0 +1,300 @@ +{# app/templates/admin/code-quality-dashboard.html #} +{% extends "admin/base.html" %} + +{% block title %}Code Quality Dashboard{% endblock %} + +{% block alpine_data %}codeQualityDashboard(){% endblock %} + +{% block extra_scripts %} + +{% endblock %} + +{% block content %} + +
+
+

+ Code Quality Dashboard +

+

+ Architecture validation and technical debt tracking +

+
+
+ + +
+
+ + +
+ +

Loading dashboard...

+
+ + +
+ +
+

Error loading dashboard

+

+
+
+ + +
+ +
+

+
+
+ + +
+ +
+ +
+
+ +
+
+

+ Total Violations +

+

+ 0 +

+
+
+ + +
+
+ +
+
+

+ Errors +

+

+ 0 +

+
+
+ + +
+
+ +
+
+

+ Warnings +

+

+ 0 +

+
+
+ + +
+
+ +
+
+

+ Health Score +

+

+ 0/100 +

+
+
+
+ + +
+ +
+

Open

+

0

+
+ + +
+

Assigned

+

0

+
+ + +
+

Resolved

+

0

+
+ + +
+

Ignored

+

0

+
+
+ + +
+ +
+

+ Violation Trends (Last 7 Scans) +

+
+ + +
+
+ + +
+

+ Top Violating Files +

+
+ + +
+
+
+ + +
+ +
+

+ Top Rules Violated +

+
+ + +
+
+ + +
+

+ Violations by Module +

+
+ + +
+
+
+ + + + + +
+ Last scan: +
+
+{% endblock %} diff --git a/app/templates/admin/code-quality-violations.html b/app/templates/admin/code-quality-violations.html new file mode 100644 index 00000000..24643a97 --- /dev/null +++ b/app/templates/admin/code-quality-violations.html @@ -0,0 +1,227 @@ +{# app/templates/admin/code-quality-violations.html #} +{% extends "admin/base.html" %} + +{% block title %}Violations List{% endblock %} + +{% block alpine_data %}codeQualityViolations(){% endblock %} + +{% block extra_scripts %} + +{% endblock %} + +{% block content %} + +
+
+

+ Architecture Violations +

+

+ Review and manage code quality violations +

+
+ + + Back to Dashboard + +
+ + +
+ +

Loading violations...

+
+ + +
+ +
+

Error loading violations

+

+
+
+ + +
+ +
+

Filters

+
+ +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+
+
+ + +
+
+ + + + + + + + + + + + + + + + +
RuleSeverityFileLineMessageStatusActions
+
+
+ + +
+
+
+ + Showing + + of + + results + +
+
+ + + Page of + + +
+
+
+
+{% endblock %} diff --git a/app/templates/admin/partials/sidebar.html b/app/templates/admin/partials/sidebar.html index fc01dfed..7c59e80f 100644 --- a/app/templates/admin/partials/sidebar.html +++ b/app/templates/admin/partials/sidebar.html @@ -125,6 +125,17 @@ Testing Hub + + +
  • + + + + Code Quality + +
  • @@ -290,6 +301,17 @@ Testing Hub + + +
  • + + + + Code Quality + +
  • diff --git a/scripts/validate_architecture.py b/scripts/validate_architecture.py index 38f2081f..dc186de8 100755 --- a/scripts/validate_architecture.py +++ b/scripts/validate_architecture.py @@ -584,6 +584,36 @@ class ArchitectureValidator: print("=" * 80) return 0 + def print_json(self) -> int: + """Print validation results as JSON""" + import json + + violations_json = [] + for v in self.result.violations: + rel_path = str(v.file_path.relative_to(self.project_root)) if self.project_root in v.file_path.parents else str(v.file_path) + violations_json.append({ + 'rule_id': v.rule_id, + 'rule_name': v.rule_name, + 'severity': v.severity.value, + 'file_path': rel_path, + 'line_number': v.line_number, + 'message': v.message, + 'context': v.context or '', + 'suggestion': v.suggestion or '' + }) + + output = { + 'files_checked': self.result.files_checked, + 'total_violations': len(self.result.violations), + 'errors': len([v for v in self.result.violations if v.severity == Severity.ERROR]), + 'warnings': len([v for v in self.result.violations if v.severity == Severity.WARNING]), + 'violations': violations_json + } + + print(json.dumps(output, indent=2)) + + return 1 if self.result.has_errors() else 0 + def _print_violation(self, v: Violation): """Print a single violation""" rel_path = v.file_path.relative_to(self.project_root) if self.project_root in v.file_path.parents else v.file_path @@ -634,6 +664,12 @@ def main(): help="Only show errors, suppress warnings" ) + parser.add_argument( + '--json', + action='store_true', + help="Output results as JSON (for programmatic use)" + ) + args = parser.parse_args() # Create validator @@ -642,8 +678,11 @@ def main(): # Run validation result = validator.validate_all(args.path) - # Print report - exit_code = validator.print_report() + # Output results + if args.json: + exit_code = validator.print_json() + else: + exit_code = validator.print_report() sys.exit(exit_code) diff --git a/static/admin/js/code-quality-dashboard.js b/static/admin/js/code-quality-dashboard.js new file mode 100644 index 00000000..eba055fd --- /dev/null +++ b/static/admin/js/code-quality-dashboard.js @@ -0,0 +1,106 @@ +/** + * Code Quality Dashboard Component + * Manages the code quality dashboard page + */ + +function codeQualityDashboard() { + return { + loading: false, + scanning: false, + error: null, + successMessage: null, + stats: { + total_violations: 0, + errors: 0, + warnings: 0, + open: 0, + assigned: 0, + resolved: 0, + ignored: 0, + technical_debt_score: 100, + trend: [], + by_severity: {}, + by_rule: {}, + by_module: {}, + top_files: [], + last_scan: null + }, + + async init() { + await this.loadStats(); + }, + + async loadStats() { + this.loading = true; + this.error = null; + + try { + const response = await fetch('/api/v1/admin/code-quality/stats', { + headers: { + 'Authorization': `Bearer ${getAccessToken()}` + } + }); + + if (!response.ok) { + if (response.status === 401) { + window.location.href = '/admin/login'; + return; + } + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + this.stats = await response.json(); + } catch (err) { + console.error('Failed to load stats:', err); + this.error = err.message; + } finally { + this.loading = false; + } + }, + + async runScan() { + this.scanning = true; + this.error = null; + this.successMessage = null; + + try { + const response = await fetch('/api/v1/admin/code-quality/scan', { + method: 'POST', + headers: { + 'Authorization': `Bearer ${getAccessToken()}`, + 'Content-Type': 'application/json' + } + }); + + if (!response.ok) { + if (response.status === 401) { + window.location.href = '/admin/login'; + return; + } + const errorData = await response.json(); + throw new Error(errorData.detail || `HTTP ${response.status}`); + } + + const scan = await response.json(); + this.successMessage = `Scan completed: ${scan.total_violations} violations found (${scan.errors} errors, ${scan.warnings} warnings)`; + + // Reload stats after scan + await this.loadStats(); + + // Clear success message after 5 seconds + setTimeout(() => { + this.successMessage = null; + }, 5000); + } catch (err) { + console.error('Failed to run scan:', err); + this.error = err.message; + } finally { + this.scanning = false; + } + }, + + async refresh() { + await this.loadStats(); + } + }; +} diff --git a/static/admin/js/code-quality-violations.js b/static/admin/js/code-quality-violations.js new file mode 100644 index 00000000..98f6273c --- /dev/null +++ b/static/admin/js/code-quality-violations.js @@ -0,0 +1,119 @@ +/** + * Code Quality Violations List Component + * Manages the violations list page with filtering and pagination + */ + +function codeQualityViolations() { + return { + loading: false, + error: null, + violations: [], + pagination: { + page: 1, + page_size: 50, + total: 0, + total_pages: 0 + }, + filters: { + severity: '', + status: '', + rule_id: '', + file_path: '' + }, + + async init() { + // Load filters from URL params + const params = new URLSearchParams(window.location.search); + this.filters.severity = params.get('severity') || ''; + this.filters.status = params.get('status') || ''; + this.filters.rule_id = params.get('rule_id') || ''; + this.filters.file_path = params.get('file_path') || ''; + + await this.loadViolations(); + }, + + async loadViolations() { + this.loading = true; + this.error = null; + + try { + // Build query params + const params = new URLSearchParams({ + page: this.pagination.page.toString(), + page_size: this.pagination.page_size.toString() + }); + + if (this.filters.severity) params.append('severity', this.filters.severity); + if (this.filters.status) params.append('status', this.filters.status); + if (this.filters.rule_id) params.append('rule_id', this.filters.rule_id); + if (this.filters.file_path) params.append('file_path', this.filters.file_path); + + const response = await fetch(`/api/v1/admin/code-quality/violations?${params.toString()}`, { + headers: { + 'Authorization': `Bearer ${getAccessToken()}` + } + }); + + if (!response.ok) { + if (response.status === 401) { + window.location.href = '/admin/login'; + return; + } + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + const data = await response.json(); + this.violations = data.violations; + this.pagination = { + page: data.page, + page_size: data.page_size, + total: data.total, + total_pages: data.total_pages + }; + + // Update URL with current filters (without reloading) + this.updateURL(); + } catch (err) { + console.error('Failed to load violations:', err); + this.error = err.message; + } finally { + this.loading = false; + } + }, + + applyFilters() { + // Reset to page 1 when filters change + this.pagination.page = 1; + this.loadViolations(); + }, + + async nextPage() { + if (this.pagination.page < this.pagination.total_pages) { + this.pagination.page++; + await this.loadViolations(); + } + }, + + async previousPage() { + if (this.pagination.page > 1) { + this.pagination.page--; + await this.loadViolations(); + } + }, + + updateURL() { + const params = new URLSearchParams(); + + if (this.filters.severity) params.set('severity', this.filters.severity); + if (this.filters.status) params.set('status', this.filters.status); + if (this.filters.rule_id) params.set('rule_id', this.filters.rule_id); + if (this.filters.file_path) params.set('file_path', this.filters.file_path); + + const newURL = params.toString() + ? `${window.location.pathname}?${params.toString()}` + : window.location.pathname; + + window.history.replaceState({}, '', newURL); + } + }; +}