feat: add database foundation for code quality dashboard (Phase 1/4)

Implemented database models and migration for tracking architecture violations
in the admin dashboard. This is the foundation for the code quality UI.

Database Models Added (app/models/architecture_scan.py):
- ArchitectureScan: Tracks validator runs with statistics
- ArchitectureViolation: Individual violations with status tracking
- ArchitectureRule: Rule configuration with enable/disable support
- ViolationAssignment: Assign violations to developers
- ViolationComment: Collaboration on violations

Schema Features:
- Full violation history tracking
- Status workflow (open → assigned → resolved/ignored/technical_debt)
- Assignment and due date tracking
- Comments for collaboration
- Rule management (enable/disable, severity override)
- Scan statistics (errors, warnings, duration)

Migration:
- Created 5 tables with proper indexes and foreign keys
- SQLite-compatible datetime defaults
- Supports relationships between scans, violations, users

Next Steps (Remaining Phases):
- Phase 2: Service layer (code_quality_service.py)
- Phase 3: API endpoints (/admin/code-quality/*)
- Phase 4: Frontend dashboard and UI

Benefits:
- Track technical debt over time
- Assign violations to developers
- Prioritize fixes by severity
- Measure code quality improvements
- Collaborative violation resolution

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-11-28 09:24:49 +01:00
parent 3c7af0ccdf
commit a44f776206
2 changed files with 267 additions and 0 deletions

View File

@@ -0,0 +1,147 @@
"""add_architecture_quality_tracking_tables
Revision ID: 7a7ce92593d5
Revises: a2064e1dfcd4
Create Date: 2025-11-28 09:21:16.545203
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = '7a7ce92593d5'
down_revision: Union[str, None] = 'a2064e1dfcd4'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Create architecture_scans table
op.create_table(
'architecture_scans',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('timestamp', sa.DateTime(timezone=True), server_default=sa.text("(datetime('now'))"), nullable=False),
sa.Column('total_files', sa.Integer(), nullable=True),
sa.Column('total_violations', sa.Integer(), nullable=True),
sa.Column('errors', sa.Integer(), nullable=True),
sa.Column('warnings', sa.Integer(), nullable=True),
sa.Column('duration_seconds', sa.Float(), nullable=True),
sa.Column('triggered_by', sa.String(length=100), nullable=True),
sa.Column('git_commit_hash', sa.String(length=40), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_architecture_scans_id'), 'architecture_scans', ['id'], unique=False)
op.create_index(op.f('ix_architecture_scans_timestamp'), 'architecture_scans', ['timestamp'], unique=False)
# Create architecture_rules table
op.create_table(
'architecture_rules',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('rule_id', sa.String(length=20), nullable=False),
sa.Column('category', sa.String(length=50), nullable=False),
sa.Column('name', sa.String(length=200), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('severity', sa.String(length=10), nullable=False),
sa.Column('enabled', sa.Boolean(), nullable=False, server_default='1'),
sa.Column('custom_config', sa.JSON(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text("(datetime('now'))"), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text("(datetime('now'))"), nullable=False),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('rule_id')
)
op.create_index(op.f('ix_architecture_rules_id'), 'architecture_rules', ['id'], unique=False)
op.create_index(op.f('ix_architecture_rules_rule_id'), 'architecture_rules', ['rule_id'], unique=True)
# Create architecture_violations table
op.create_table(
'architecture_violations',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('scan_id', sa.Integer(), nullable=False),
sa.Column('rule_id', sa.String(length=20), nullable=False),
sa.Column('rule_name', sa.String(length=200), nullable=False),
sa.Column('severity', sa.String(length=10), nullable=False),
sa.Column('file_path', sa.String(length=500), nullable=False),
sa.Column('line_number', sa.Integer(), nullable=False),
sa.Column('message', sa.Text(), nullable=False),
sa.Column('context', sa.Text(), nullable=True),
sa.Column('suggestion', sa.Text(), nullable=True),
sa.Column('status', sa.String(length=20), server_default='open', nullable=True),
sa.Column('assigned_to', sa.Integer(), nullable=True),
sa.Column('resolved_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('resolved_by', sa.Integer(), nullable=True),
sa.Column('resolution_note', sa.Text(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text("(datetime('now'))"), nullable=False),
sa.ForeignKeyConstraint(['assigned_to'], ['users.id'], ),
sa.ForeignKeyConstraint(['resolved_by'], ['users.id'], ),
sa.ForeignKeyConstraint(['scan_id'], ['architecture_scans.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_architecture_violations_file_path'), 'architecture_violations', ['file_path'], unique=False)
op.create_index(op.f('ix_architecture_violations_id'), 'architecture_violations', ['id'], unique=False)
op.create_index(op.f('ix_architecture_violations_rule_id'), 'architecture_violations', ['rule_id'], unique=False)
op.create_index(op.f('ix_architecture_violations_scan_id'), 'architecture_violations', ['scan_id'], unique=False)
op.create_index(op.f('ix_architecture_violations_severity'), 'architecture_violations', ['severity'], unique=False)
op.create_index(op.f('ix_architecture_violations_status'), 'architecture_violations', ['status'], unique=False)
# Create violation_assignments table
op.create_table(
'violation_assignments',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('violation_id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('assigned_at', sa.DateTime(timezone=True), server_default=sa.text("(datetime('now'))"), nullable=False),
sa.Column('assigned_by', sa.Integer(), nullable=True),
sa.Column('due_date', sa.DateTime(timezone=True), nullable=True),
sa.Column('priority', sa.String(length=10), server_default='medium', nullable=True),
sa.ForeignKeyConstraint(['assigned_by'], ['users.id'], ),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.ForeignKeyConstraint(['violation_id'], ['architecture_violations.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_violation_assignments_id'), 'violation_assignments', ['id'], unique=False)
op.create_index(op.f('ix_violation_assignments_violation_id'), 'violation_assignments', ['violation_id'], unique=False)
# Create violation_comments table
op.create_table(
'violation_comments',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('violation_id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('comment', sa.Text(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text("(datetime('now'))"), nullable=False),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.ForeignKeyConstraint(['violation_id'], ['architecture_violations.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_violation_comments_id'), 'violation_comments', ['id'], unique=False)
op.create_index(op.f('ix_violation_comments_violation_id'), 'violation_comments', ['violation_id'], unique=False)
def downgrade() -> None:
# Drop tables in reverse order (to respect foreign key constraints)
op.drop_index(op.f('ix_violation_comments_violation_id'), table_name='violation_comments')
op.drop_index(op.f('ix_violation_comments_id'), table_name='violation_comments')
op.drop_table('violation_comments')
op.drop_index(op.f('ix_violation_assignments_violation_id'), table_name='violation_assignments')
op.drop_index(op.f('ix_violation_assignments_id'), table_name='violation_assignments')
op.drop_table('violation_assignments')
op.drop_index(op.f('ix_architecture_violations_status'), table_name='architecture_violations')
op.drop_index(op.f('ix_architecture_violations_severity'), table_name='architecture_violations')
op.drop_index(op.f('ix_architecture_violations_scan_id'), table_name='architecture_violations')
op.drop_index(op.f('ix_architecture_violations_rule_id'), table_name='architecture_violations')
op.drop_index(op.f('ix_architecture_violations_id'), table_name='architecture_violations')
op.drop_index(op.f('ix_architecture_violations_file_path'), table_name='architecture_violations')
op.drop_table('architecture_violations')
op.drop_index(op.f('ix_architecture_rules_rule_id'), table_name='architecture_rules')
op.drop_index(op.f('ix_architecture_rules_id'), table_name='architecture_rules')
op.drop_table('architecture_rules')
op.drop_index(op.f('ix_architecture_scans_timestamp'), table_name='architecture_scans')
op.drop_index(op.f('ix_architecture_scans_id'), table_name='architecture_scans')
op.drop_table('architecture_scans')

View File

@@ -0,0 +1,120 @@
"""
Architecture Scan Models
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
class ArchitectureScan(Base):
"""Represents a single run of the architecture validator"""
__tablename__ = "architecture_scans"
id = Column(Integer, primary_key=True, index=True)
timestamp = Column(DateTime(timezone=True), server_default=func.now(), nullable=False, index=True)
total_files = Column(Integer, default=0)
total_violations = Column(Integer, default=0)
errors = Column(Integer, default=0)
warnings = Column(Integer, default=0)
duration_seconds = Column(Float, default=0.0)
triggered_by = Column(String(100)) # 'manual', 'scheduled', 'ci/cd'
git_commit_hash = Column(String(40))
# Relationship to violations
violations = relationship("ArchitectureViolation", back_populates="scan", cascade="all, delete-orphan")
def __repr__(self):
return f"<ArchitectureScan(id={self.id}, violations={self.total_violations}, errors={self.errors})>"
class ArchitectureViolation(Base):
"""Represents a single architectural violation found during a scan"""
__tablename__ = "architecture_violations"
id = Column(Integer, primary_key=True, index=True)
scan_id = Column(Integer, ForeignKey("architecture_scans.id"), nullable=False, index=True)
rule_id = Column(String(20), nullable=False, index=True) # e.g., 'API-001'
rule_name = Column(String(200), nullable=False)
severity = Column(String(10), nullable=False, index=True) # 'error', 'warning', 'info'
file_path = Column(String(500), nullable=False, index=True)
line_number = Column(Integer, nullable=False)
message = Column(Text, nullable=False)
context = Column(Text) # Code snippet
suggestion = Column(Text)
status = Column(String(20), default='open', index=True) # 'open', 'assigned', 'resolved', 'ignored', 'technical_debt'
assigned_to = Column(Integer, ForeignKey("users.id"))
resolved_at = Column(DateTime(timezone=True))
resolved_by = Column(Integer, ForeignKey("users.id"))
resolution_note = Column(Text)
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
# Relationships
scan = relationship("ArchitectureScan", back_populates="violations")
assigned_user = relationship("User", foreign_keys=[assigned_to], backref="assigned_violations")
resolver = relationship("User", foreign_keys=[resolved_by], backref="resolved_violations")
assignments = relationship("ViolationAssignment", back_populates="violation", cascade="all, delete-orphan")
comments = relationship("ViolationComment", back_populates="violation", cascade="all, delete-orphan")
def __repr__(self):
return f"<ArchitectureViolation(id={self.id}, rule={self.rule_id}, file={self.file_path}:{self.line_number})>"
class ArchitectureRule(Base):
"""Architecture rules configuration (from YAML with database overrides)"""
__tablename__ = "architecture_rules"
id = Column(Integer, primary_key=True, index=True)
rule_id = Column(String(20), unique=True, nullable=False, index=True) # e.g., 'API-001'
category = Column(String(50), nullable=False) # 'api_endpoint', 'service_layer', etc.
name = Column(String(200), nullable=False)
description = Column(Text)
severity = Column(String(10), nullable=False) # Can override default from YAML
enabled = Column(Boolean, default=True, nullable=False)
custom_config = Column(JSON) # For rule-specific settings
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
def __repr__(self):
return f"<ArchitectureRule(id={self.rule_id}, name={self.name}, enabled={self.enabled})>"
class ViolationAssignment(Base):
"""Tracks assignment of violations to developers"""
__tablename__ = "violation_assignments"
id = Column(Integer, primary_key=True, index=True)
violation_id = Column(Integer, ForeignKey("architecture_violations.id"), nullable=False, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
assigned_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
assigned_by = Column(Integer, ForeignKey("users.id"))
due_date = Column(DateTime(timezone=True))
priority = Column(String(10), default='medium') # 'low', 'medium', 'high', 'critical'
# Relationships
violation = relationship("ArchitectureViolation", back_populates="assignments")
user = relationship("User", foreign_keys=[user_id], backref="violation_assignments")
assigner = relationship("User", foreign_keys=[assigned_by], backref="assigned_by_me")
def __repr__(self):
return f"<ViolationAssignment(id={self.id}, violation_id={self.violation_id}, user_id={self.user_id})>"
class ViolationComment(Base):
"""Comments on violations for collaboration"""
__tablename__ = "violation_comments"
id = Column(Integer, primary_key=True, index=True)
violation_id = Column(Integer, ForeignKey("architecture_violations.id"), nullable=False, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
comment = Column(Text, nullable=False)
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
# Relationships
violation = relationship("ArchitectureViolation", back_populates="comments")
user = relationship("User", backref="violation_comments")
def __repr__(self):
return f"<ViolationComment(id={self.id}, violation_id={self.violation_id}, user_id={self.user_id})>"