refactor: migrate templates and static files to self-contained modules

Templates Migration:
- Migrate admin templates to modules (tenancy, billing, monitoring, marketplace, etc.)
- Migrate vendor templates to modules (tenancy, billing, orders, messaging, etc.)
- Migrate storefront templates to modules (catalog, customers, orders, cart, checkout, cms)
- Migrate public templates to modules (billing, marketplace, cms)
- Keep shared templates in app/templates/ (base.html, errors/, partials/, macros/)
- Migrate letzshop partials to marketplace module

Static Files Migration:
- Migrate admin JS to modules: tenancy (23 files), core (5 files), monitoring (1 file)
- Migrate vendor JS to modules: tenancy (4 files), core (2 files)
- Migrate shared JS: vendor-selector.js to core, media-picker.js to cms
- Migrate storefront JS: storefront-layout.js to core
- Keep framework JS in static/ (api-client, utils, money, icons, log-config, lib/)
- Update all template references to use module_static paths

Naming Consistency:
- Rename static/platform/ to static/public/
- Rename app/templates/platform/ to app/templates/public/
- Update all extends and static references

Documentation:
- Update module-system.md with shared templates documentation
- Update frontend-structure.md with new module JS organization

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-01 14:34:16 +01:00
parent 843703258f
commit 4e28d91a78
542 changed files with 11603 additions and 9037 deletions

View File

@@ -1,4 +1,14 @@
# Routes will be migrated here from legacy locations
# TODO: Move actual route implementations from app/api/v1/
# app/modules/monitoring/routes/api/__init__.py
"""
Monitoring module API routes.
__all__ = []
Admin routes:
- /logs/* - Application log management
- /tasks/* - Background tasks monitoring
- /tests/* - Test runner
- /code-quality/* - Code quality tools
"""
from app.modules.monitoring.routes.api.admin import admin_router
__all__ = ["admin_router"]

View File

@@ -0,0 +1,35 @@
# app/modules/monitoring/routes/api/admin.py
"""
Monitoring module admin API routes.
Aggregates all admin monitoring routes:
- /logs/* - Application log management
- /tasks/* - Background tasks monitoring
- /tests/* - Test runner
- /code-quality/* - Code quality tools
- /audit/* - Admin audit logging
- /platform/* - Platform health and capacity
"""
from fastapi import APIRouter, Depends
from app.api.deps import require_module_access
from .admin_logs import admin_logs_router
from .admin_tasks import admin_tasks_router
from .admin_tests import admin_tests_router
from .admin_code_quality import admin_code_quality_router
from .admin_audit import admin_audit_router
from .admin_platform_health import admin_platform_health_router
admin_router = APIRouter(
dependencies=[Depends(require_module_access("monitoring"))],
)
# Aggregate all monitoring admin routes
admin_router.include_router(admin_logs_router, tags=["admin-logs"])
admin_router.include_router(admin_tasks_router, tags=["admin-tasks"])
admin_router.include_router(admin_tests_router, tags=["admin-tests"])
admin_router.include_router(admin_code_quality_router, tags=["admin-code-quality"])
admin_router.include_router(admin_audit_router, tags=["admin-audit"])
admin_router.include_router(admin_platform_health_router, tags=["admin-platform-health"])

View File

@@ -0,0 +1,105 @@
# app/modules/monitoring/routes/api/admin_audit.py
"""
Admin audit log endpoints.
Provides endpoints for:
- Viewing audit logs with filtering
- Tracking admin actions
- Generating audit reports
"""
import logging
from datetime import datetime
from fastapi import APIRouter, Depends, Query
from sqlalchemy.orm import Session
from app.api.deps import get_current_admin_api
from app.core.database import get_db
from app.modules.monitoring.services.admin_audit_service import admin_audit_service
from models.schema.auth import UserContext
from models.schema.admin import (
AdminAuditLogFilters,
AdminAuditLogListResponse,
AdminAuditLogResponse,
)
admin_audit_router = APIRouter(prefix="/audit")
logger = logging.getLogger(__name__)
@admin_audit_router.get("/logs", response_model=AdminAuditLogListResponse)
def get_audit_logs(
admin_user_id: int | None = Query(None, description="Filter by admin user"),
action: str | None = Query(None, description="Filter by action type"),
target_type: str | None = Query(None, description="Filter by target type"),
date_from: datetime | None = Query(None, description="Filter from date"),
date_to: datetime | None = Query(None, description="Filter to date"),
skip: int = Query(0, ge=0, description="Number of records to skip"),
limit: int = Query(100, ge=1, le=1000, description="Maximum records to return"),
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
):
"""
Get filtered admin audit logs.
Returns paginated list of all admin actions with filtering options.
Useful for compliance, security audits, and tracking admin activities.
"""
filters = AdminAuditLogFilters(
admin_user_id=admin_user_id,
action=action,
target_type=target_type,
date_from=date_from,
date_to=date_to,
skip=skip,
limit=limit,
)
logs = admin_audit_service.get_audit_logs(db, filters)
total = admin_audit_service.get_audit_logs_count(db, filters)
logger.info(f"Admin {current_admin.username} retrieved {len(logs)} audit logs")
return AdminAuditLogListResponse(logs=logs, total=total, skip=skip, limit=limit)
@admin_audit_router.get("/logs/recent", response_model=list[AdminAuditLogResponse])
def get_recent_audit_logs(
limit: int = Query(20, ge=1, le=100),
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
):
"""Get recent audit logs (last 20 by default)."""
filters = AdminAuditLogFilters(limit=limit)
return admin_audit_service.get_audit_logs(db, filters)
@admin_audit_router.get("/logs/my-actions", response_model=list[AdminAuditLogResponse])
def get_my_actions(
limit: int = Query(50, ge=1, le=100),
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
):
"""Get audit logs for current admin's actions."""
return admin_audit_service.get_recent_actions_by_admin(
db=db, admin_user_id=current_admin.id, limit=limit
)
@admin_audit_router.get("/logs/target/{target_type}/{target_id}")
def get_actions_by_target(
target_type: str,
target_id: str,
limit: int = Query(50, ge=1, le=100),
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
):
"""
Get all actions performed on a specific target.
Useful for tracking the history of a specific vendor, user, or entity.
"""
return admin_audit_service.get_actions_by_target(
db=db, target_type=target_type, target_id=target_id, limit=limit
)

View File

@@ -0,0 +1,619 @@
# app/modules/monitoring/routes/api/admin_code_quality.py
"""
Code Quality API Endpoints
RESTful API for code quality validation and violation management
Supports multiple validator types: architecture, security, performance
"""
from datetime import datetime
from enum import Enum
from fastapi import APIRouter, BackgroundTasks, 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.modules.monitoring.exceptions import ScanNotFoundException, ViolationNotFoundException
from app.modules.dev_tools.services.code_quality_service import (
VALID_VALIDATOR_TYPES,
code_quality_service,
)
from app.modules.dev_tools.models import ArchitectureScan
from models.schema.auth import UserContext
from app.modules.analytics.schemas import CodeQualityDashboardStatsResponse
admin_code_quality_router = APIRouter(prefix="/code-quality")
# Enums and Constants
class ValidatorType(str, Enum):
"""Supported validator types"""
ARCHITECTURE = "architecture"
SECURITY = "security"
PERFORMANCE = "performance"
# Pydantic Models for API
class ScanResponse(BaseModel):
"""Response model for a scan"""
id: int
timestamp: str
validator_type: str
status: str
started_at: str | None
completed_at: str | None
progress_message: str | None
total_files: int
total_violations: int
errors: int
warnings: int
duration_seconds: float
triggered_by: str | None
git_commit_hash: str | None
error_message: str | None = None
class Config:
from_attributes = True
class ScanRequest(BaseModel):
"""Request model for triggering scans"""
validator_types: list[ValidatorType] = Field(
default=[ValidatorType.ARCHITECTURE, ValidatorType.SECURITY, ValidatorType.PERFORMANCE],
description="Validator types to run",
)
class ScanJobResponse(BaseModel):
"""Response model for a queued scan job"""
id: int
validator_type: str
status: str
message: str
class MultiScanJobResponse(BaseModel):
"""Response model for multiple queued scans (background task pattern)"""
scans: list[ScanJobResponse]
message: str
status_url: str
class MultiScanResponse(BaseModel):
"""Response model for completed scans (legacy sync pattern)"""
scans: list[ScanResponse]
total_violations: int
total_errors: int
total_warnings: int
class ViolationResponse(BaseModel):
"""Response model for a violation"""
id: int
scan_id: int
validator_type: str
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")
# API Endpoints
def _scan_to_response(scan: ArchitectureScan) -> ScanResponse:
"""Convert ArchitectureScan to ScanResponse."""
return ScanResponse(
id=scan.id,
timestamp=scan.timestamp.isoformat() if scan.timestamp else None,
validator_type=scan.validator_type,
status=scan.status,
started_at=scan.started_at.isoformat() if scan.started_at else None,
completed_at=scan.completed_at.isoformat() if scan.completed_at else None,
progress_message=scan.progress_message,
total_files=scan.total_files or 0,
total_violations=scan.total_violations or 0,
errors=scan.errors or 0,
warnings=scan.warnings or 0,
duration_seconds=scan.duration_seconds or 0.0,
triggered_by=scan.triggered_by,
git_commit_hash=scan.git_commit_hash,
error_message=scan.error_message,
)
@admin_code_quality_router.post("/scan", response_model=MultiScanJobResponse, status_code=202)
async def trigger_scan(
request: ScanRequest = None,
background_tasks: BackgroundTasks = None,
db: Session = Depends(get_db),
current_user: UserContext = Depends(get_current_admin_api),
):
"""
Trigger code quality scan(s) as background tasks.
By default runs all validators. Specify validator_types to run specific validators.
Returns immediately with job IDs. Poll /scan/{scan_id}/status for progress.
Scans run asynchronously - users can browse other pages while scans execute.
"""
if request is None:
request = ScanRequest()
scan_jobs = []
triggered_by = f"manual:{current_user.username}"
# Import dispatcher for Celery support
from app.tasks.dispatcher import task_dispatcher
for vtype in request.validator_types:
# Create scan record with pending status via service
scan = code_quality_service.create_pending_scan(
db, validator_type=vtype.value, triggered_by=triggered_by
)
# Dispatch via task dispatcher (supports Celery or BackgroundTasks)
celery_task_id = task_dispatcher.dispatch_code_quality_scan(
background_tasks=background_tasks,
scan_id=scan.id,
)
# Store Celery task ID if using Celery
if celery_task_id:
scan.celery_task_id = celery_task_id
scan_jobs.append(
ScanJobResponse(
id=scan.id,
validator_type=vtype.value,
status="pending",
message=f"{vtype.value.capitalize()} scan queued",
)
)
db.commit()
validator_names = ", ".join(vtype.value for vtype in request.validator_types)
return MultiScanJobResponse(
scans=scan_jobs,
message=f"Scans queued for: {validator_names}",
status_url="/admin/code-quality/scans/running",
)
@admin_code_quality_router.get("/scans/{scan_id}/status", response_model=ScanResponse)
async def get_scan_status(
scan_id: int,
db: Session = Depends(get_db),
current_user: UserContext = Depends(get_current_admin_api),
):
"""
Get status of a specific scan.
Use this endpoint to poll for scan completion.
"""
scan = code_quality_service.get_scan_by_id(db, scan_id)
if not scan:
raise ScanNotFoundException(scan_id)
return _scan_to_response(scan)
@admin_code_quality_router.get("/scans/running", response_model=list[ScanResponse])
async def get_running_scans(
db: Session = Depends(get_db),
current_user: UserContext = Depends(get_current_admin_api),
):
"""
Get all currently running scans.
Returns scans with status 'pending' or 'running'.
"""
scans = code_quality_service.get_running_scans(db)
return [_scan_to_response(scan) for scan in scans]
@admin_code_quality_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"),
validator_type: ValidatorType | None = Query(
None, description="Filter by validator type"
),
db: Session = Depends(get_db),
current_user: UserContext = Depends(get_current_admin_api),
):
"""
Get scan history
Returns recent scans for trend analysis.
Optionally filter by validator type.
"""
scans = code_quality_service.get_scan_history(
db, limit=limit, validator_type=validator_type.value if validator_type else None
)
return [_scan_to_response(scan) for scan in scans]
@admin_code_quality_router.get("/violations", response_model=ViolationListResponse)
async def list_violations(
scan_id: int | None = Query(
None, description="Filter by scan ID (defaults to latest)"
),
validator_type: ValidatorType | None = Query(
None, description="Filter by validator type"
),
severity: str | None = Query(
None, description="Filter by severity (error, warning, info)"
),
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: UserContext = Depends(get_current_admin_api),
):
"""
Get violations with filtering and pagination
Returns violations from latest scan(s) by default.
Filter by validator_type to get violations from a specific validator.
"""
offset = (page - 1) * page_size
violations, total = code_quality_service.get_violations(
db,
scan_id=scan_id,
validator_type=validator_type.value if validator_type else None,
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,
validator_type=v.validator_type,
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,
)
@admin_code_quality_router.get("/violations/{violation_id}", response_model=ViolationDetailResponse)
async def get_violation(
violation_id: int,
db: Session = Depends(get_db),
current_user: UserContext = 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,
validator_type=violation.validator_type,
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,
)
@admin_code_quality_router.post("/violations/{violation_id}/assign")
async def assign_violation(
violation_id: int,
request: AssignViolationRequest,
db: Session = Depends(get_db),
current_user: UserContext = 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,
}
@admin_code_quality_router.post("/violations/{violation_id}/resolve")
async def resolve_violation(
violation_id: int,
request: ResolveViolationRequest,
db: Session = Depends(get_db),
current_user: UserContext = 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,
}
@admin_code_quality_router.post("/violations/{violation_id}/ignore")
async def ignore_violation(
violation_id: int,
request: IgnoreViolationRequest,
db: Session = Depends(get_db),
current_user: UserContext = 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,
}
@admin_code_quality_router.post("/violations/{violation_id}/comments")
async def add_comment(
violation_id: int,
request: AddCommentRequest,
db: Session = Depends(get_db),
current_user: UserContext = 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(),
}
@admin_code_quality_router.get("/stats", response_model=CodeQualityDashboardStatsResponse)
async def get_dashboard_stats(
validator_type: ValidatorType | None = Query(
None, description="Filter by validator type (returns combined stats if not specified)"
),
db: Session = Depends(get_db),
current_user: UserContext = 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
- Per-validator breakdown
When validator_type is specified, returns stats for that type only.
When not specified, returns combined stats across all validators.
"""
stats = code_quality_service.get_dashboard_stats(
db, validator_type=validator_type.value if validator_type else None
)
return CodeQualityDashboardStatsResponse(**stats)
@admin_code_quality_router.get("/validator-types")
async def get_validator_types(
current_user: UserContext = Depends(get_current_admin_api),
):
"""
Get list of available validator types
Returns the supported validator types for filtering.
"""
return {
"validator_types": VALID_VALIDATOR_TYPES,
"descriptions": {
"architecture": "Architectural patterns and code organization rules",
"security": "Security vulnerabilities and best practices",
"performance": "Performance issues and optimizations",
},
}

View File

@@ -0,0 +1,343 @@
# app/modules/monitoring/routes/api/admin_logs.py
"""
Log management endpoints for admin.
Provides endpoints for:
- Viewing database logs with filters
- Reading file logs
- Log statistics
- Log settings management
- Log cleanup operations
"""
import logging
from fastapi import APIRouter, Depends, Query
from sqlalchemy.orm import Session
from app.api.deps import get_current_admin_api
from app.core.database import get_db
from app.core.logging import reload_log_level
from app.exceptions import ResourceNotFoundException
from app.modules.tenancy.exceptions import ConfirmationRequiredException
from app.modules.monitoring.services.admin_audit_service import admin_audit_service
from app.modules.core.services.admin_settings_service import admin_settings_service
from app.modules.monitoring.services.log_service import log_service
from models.schema.auth import UserContext
from models.schema.admin import (
ApplicationLogFilters,
ApplicationLogListResponse,
FileLogResponse,
LogCleanupResponse,
LogDeleteResponse,
LogFileListResponse,
LogSettingsResponse,
LogSettingsUpdate,
LogSettingsUpdateResponse,
LogStatistics,
)
admin_logs_router = APIRouter(prefix="/logs")
logger = logging.getLogger(__name__)
# ============================================================================
# DATABASE LOGS ENDPOINTS
# ============================================================================
@admin_logs_router.get("/database", response_model=ApplicationLogListResponse)
def get_database_logs(
level: str | None = Query(None, description="Filter by log level"),
logger_name: str | None = Query(None, description="Filter by logger name"),
module: str | None = Query(None, description="Filter by module"),
user_id: int | None = Query(None, description="Filter by user ID"),
vendor_id: int | None = Query(None, description="Filter by vendor ID"),
search: str | None = Query(None, description="Search in message"),
skip: int = Query(0, ge=0),
limit: int = Query(100, ge=1, le=1000),
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
):
"""
Get logs from database with filtering.
Supports filtering by level, logger, module, user, vendor, and date range.
Returns paginated results.
"""
filters = ApplicationLogFilters(
level=level,
logger_name=logger_name,
module=module,
user_id=user_id,
vendor_id=vendor_id,
search=search,
skip=skip,
limit=limit,
)
return log_service.get_database_logs(db, filters)
@admin_logs_router.get("/statistics", response_model=LogStatistics)
def get_log_statistics(
days: int = Query(7, ge=1, le=90, description="Number of days to analyze"),
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
):
"""
Get log statistics for the last N days.
Returns counts by level, module, and recent critical errors.
"""
return log_service.get_log_statistics(db, days)
@admin_logs_router.delete("/database/cleanup", response_model=LogCleanupResponse)
def cleanup_old_logs(
retention_days: int = Query(30, ge=1, le=365),
confirm: bool = Query(False, description="Must be true to confirm cleanup"),
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
):
"""
Delete logs older than retention period.
Requires confirmation parameter.
"""
if not confirm:
raise ConfirmationRequiredException(operation="cleanup_logs")
deleted_count = log_service.cleanup_old_logs(db, retention_days)
# Log action
admin_audit_service.log_action(
db=db,
admin_user_id=current_admin.id,
action="cleanup_logs",
target_type="application_logs",
target_id="bulk",
details={"retention_days": retention_days, "deleted_count": deleted_count},
)
return LogCleanupResponse(
message=f"Deleted {deleted_count} log entries older than {retention_days} days",
deleted_count=deleted_count,
)
@admin_logs_router.delete("/database/{log_id}", response_model=LogDeleteResponse)
def delete_log(
log_id: int,
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
):
"""Delete a specific log entry."""
message = log_service.delete_log(db, log_id)
# Log action
admin_audit_service.log_action(
db=db,
admin_user_id=current_admin.id,
action="delete_log",
target_type="application_log",
target_id=str(log_id),
details={},
)
return LogDeleteResponse(message=message)
# ============================================================================
# FILE LOGS ENDPOINTS
# ============================================================================
@admin_logs_router.get("/files", response_model=LogFileListResponse)
def list_log_files(
current_admin: UserContext = Depends(get_current_admin_api),
):
"""
List all available log files.
Returns list of log files with size and modification date.
"""
return LogFileListResponse(files=log_service.list_log_files())
@admin_logs_router.get("/files/{filename}", response_model=FileLogResponse)
def get_file_log(
filename: str,
lines: int = Query(500, ge=1, le=10000, description="Number of lines to read"),
current_admin: UserContext = Depends(get_current_admin_api),
):
"""
Read log file content.
Returns the last N lines from the specified log file.
"""
return log_service.get_file_logs(filename, lines)
@admin_logs_router.get("/files/{filename}/download")
def download_log_file(
filename: str,
current_admin: UserContext = Depends(get_current_admin_api),
):
"""
Download log file.
Returns the entire log file for download.
"""
from pathlib import Path
from fastapi.responses import FileResponse
from app.core.config import settings
# Determine log file path
log_file_path = settings.log_file
if log_file_path:
log_file = Path(log_file_path).parent / filename
else:
log_file = Path("logs") / filename
if not log_file.exists():
raise ResourceNotFoundException(resource_type="LogFile", identifier=filename)
# Log action
from app.core.database import get_db
db_gen = get_db()
db = next(db_gen)
try:
admin_audit_service.log_action(
db=db,
admin_user_id=current_admin.id,
action="download_log_file",
target_type="log_file",
target_id=filename,
details={"size_bytes": log_file.stat().st_size},
)
finally:
db.close()
return FileResponse(
log_file,
media_type="text/plain",
filename=filename,
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
# ============================================================================
# LOG SETTINGS ENDPOINTS
# ============================================================================
@admin_logs_router.get("/settings", response_model=LogSettingsResponse)
def get_log_settings(
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
):
"""Get current log configuration settings."""
log_level = admin_settings_service.get_setting_value(db, "log_level", "INFO")
max_size_mb = admin_settings_service.get_setting_value(
db, "log_file_max_size_mb", 10
)
backup_count = admin_settings_service.get_setting_value(
db, "log_file_backup_count", 5
)
retention_days = admin_settings_service.get_setting_value(
db, "db_log_retention_days", 30
)
file_enabled = admin_settings_service.get_setting_value(
db, "file_logging_enabled", "true"
)
db_enabled = admin_settings_service.get_setting_value(
db, "db_logging_enabled", "true"
)
return LogSettingsResponse(
log_level=str(log_level),
log_file_max_size_mb=int(max_size_mb),
log_file_backup_count=int(backup_count),
db_log_retention_days=int(retention_days),
file_logging_enabled=str(file_enabled).lower() == "true",
db_logging_enabled=str(db_enabled).lower() == "true",
)
@admin_logs_router.put("/settings", response_model=LogSettingsUpdateResponse)
def update_log_settings(
settings_update: LogSettingsUpdate,
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
):
"""
Update log configuration settings.
Changes are applied immediately without restart (for log level).
File rotation settings require restart.
"""
from models.schema.admin import AdminSettingUpdate
updated = []
# Update log level
if settings_update.log_level:
admin_settings_service.update_setting(
db,
"log_level",
AdminSettingUpdate(value=settings_update.log_level),
current_admin.id,
)
updated.append("log_level")
# Reload log level immediately
reload_log_level()
# Update file rotation settings
if settings_update.log_file_max_size_mb:
admin_settings_service.update_setting(
db,
"log_file_max_size_mb",
AdminSettingUpdate(value=str(settings_update.log_file_max_size_mb)),
current_admin.id,
)
updated.append("log_file_max_size_mb")
if settings_update.log_file_backup_count is not None:
admin_settings_service.update_setting(
db,
"log_file_backup_count",
AdminSettingUpdate(value=str(settings_update.log_file_backup_count)),
current_admin.id,
)
updated.append("log_file_backup_count")
# Update retention
if settings_update.db_log_retention_days:
admin_settings_service.update_setting(
db,
"db_log_retention_days",
AdminSettingUpdate(value=str(settings_update.db_log_retention_days)),
current_admin.id,
)
updated.append("db_log_retention_days")
# Log action
admin_audit_service.log_action(
db=db,
admin_user_id=current_admin.id,
action="update_log_settings",
target_type="settings",
target_id="logging",
details={"updated_fields": updated},
)
return LogSettingsUpdateResponse(
message="Log settings updated successfully",
updated_fields=updated,
note="Log level changes are applied immediately. File rotation settings require restart.",
)

View File

@@ -0,0 +1,214 @@
# app/modules/monitoring/routes/api/admin_platform_health.py
"""
Platform health and capacity monitoring endpoints.
Provides:
- Overall platform health status
- Capacity metrics and thresholds
- Scaling recommendations
"""
import logging
from fastapi import APIRouter, Depends
from pydantic import BaseModel
from sqlalchemy.orm import Session
from app.api.deps import get_current_admin_api
from app.core.database import get_db
from app.modules.monitoring.services.platform_health_service import platform_health_service
from models.schema.auth import UserContext
admin_platform_health_router = APIRouter(prefix="/platform")
logger = logging.getLogger(__name__)
# ============================================================================
# Schemas
# ============================================================================
class SystemMetrics(BaseModel):
"""System resource metrics."""
cpu_percent: float
memory_percent: float
memory_used_gb: float
memory_total_gb: float
disk_percent: float
disk_used_gb: float
disk_total_gb: float
class DatabaseMetrics(BaseModel):
"""Database metrics."""
size_mb: float
products_count: int
orders_count: int
vendors_count: int
inventory_count: int
class ImageStorageMetrics(BaseModel):
"""Image storage metrics."""
total_files: int
total_size_mb: float
total_size_gb: float
max_files_per_dir: int
products_estimated: int
class CapacityThreshold(BaseModel):
"""Capacity threshold status."""
name: str
current: float
warning: float
critical: float
limit: float
status: str # ok, warning, critical
percent_used: float
class ScalingRecommendation(BaseModel):
"""Scaling recommendation."""
priority: str # info, warning, critical
title: str
description: str
action: str | None = None
class PlatformHealthResponse(BaseModel):
"""Complete platform health response."""
timestamp: str
overall_status: str # healthy, degraded, critical
system: SystemMetrics
database: DatabaseMetrics
image_storage: ImageStorageMetrics
thresholds: list[CapacityThreshold]
recommendations: list[ScalingRecommendation]
infrastructure_tier: str
next_tier_trigger: str | None = None
class CapacityMetricsResponse(BaseModel):
"""Capacity-focused metrics."""
products_total: int
products_by_vendor: dict[str, int]
images_total: int
storage_used_gb: float
database_size_mb: float
orders_this_month: int
active_vendors: int
# ============================================================================
# Endpoints
# ============================================================================
@admin_platform_health_router.get("/health", response_model=PlatformHealthResponse)
async def get_platform_health(
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
):
"""Get comprehensive platform health status.
Returns system metrics, database stats, storage info, and recommendations.
"""
health_data = platform_health_service.get_full_health_report(db)
return PlatformHealthResponse(
timestamp=health_data["timestamp"],
overall_status=health_data["overall_status"],
system=SystemMetrics(**health_data["system"]),
database=DatabaseMetrics(**health_data["database"]),
image_storage=ImageStorageMetrics(**health_data["image_storage"]),
thresholds=[CapacityThreshold(**t) for t in health_data["thresholds"]],
recommendations=[ScalingRecommendation(**r) for r in health_data["recommendations"]],
infrastructure_tier=health_data["infrastructure_tier"],
next_tier_trigger=health_data["next_tier_trigger"],
)
@admin_platform_health_router.get("/capacity", response_model=CapacityMetricsResponse)
async def get_capacity_metrics(
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
):
"""Get capacity-focused metrics for planning."""
metrics = platform_health_service.get_capacity_metrics(db)
return CapacityMetricsResponse(**metrics)
@admin_platform_health_router.get("/subscription-capacity")
async def get_subscription_capacity(
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
):
"""
Get subscription-based capacity metrics.
Shows theoretical vs actual capacity based on all vendor subscriptions.
"""
return platform_health_service.get_subscription_capacity(db)
@admin_platform_health_router.get("/trends")
async def get_growth_trends(
days: int = 30,
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
):
"""
Get growth trends over the specified period.
Returns growth rates and projections for key metrics.
"""
from app.modules.billing.services.capacity_forecast_service import capacity_forecast_service
return capacity_forecast_service.get_growth_trends(db, days=days)
@admin_platform_health_router.get("/recommendations")
async def get_scaling_recommendations(
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
):
"""
Get scaling recommendations based on current capacity and growth.
Returns prioritized list of recommendations.
"""
from app.modules.billing.services.capacity_forecast_service import capacity_forecast_service
return capacity_forecast_service.get_scaling_recommendations(db)
@admin_platform_health_router.post("/snapshot")
async def capture_snapshot(
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
):
"""
Manually capture a capacity snapshot.
Normally run automatically by daily background job.
"""
from app.modules.billing.services.capacity_forecast_service import capacity_forecast_service
snapshot = capacity_forecast_service.capture_daily_snapshot(db)
db.commit()
return {
"id": snapshot.id,
"snapshot_date": snapshot.snapshot_date.isoformat(),
"total_vendors": snapshot.total_vendors,
"total_products": snapshot.total_products,
"message": "Snapshot captured successfully",
}

View File

@@ -0,0 +1,258 @@
# app/modules/monitoring/routes/api/admin_tasks.py
"""
Background Tasks Monitoring API
Provides unified view of all background tasks across the system
"""
from datetime import UTC, datetime
from fastapi import APIRouter, Depends, Query
from pydantic import BaseModel
from sqlalchemy.orm import Session
from app.api.deps import get_current_admin_api
from app.core.database import get_db
from app.modules.monitoring.services.background_tasks_service import background_tasks_service
from models.schema.auth import UserContext
admin_tasks_router = APIRouter(prefix="/tasks")
class BackgroundTaskResponse(BaseModel):
"""Unified background task response"""
id: int
task_type: str # 'import', 'test_run', or 'code_quality_scan'
status: str
started_at: str | None
completed_at: str | None
duration_seconds: float | None
description: str
triggered_by: str | None
error_message: str | None
details: dict | None
celery_task_id: str | None = None # Celery task ID for Flower linking
class BackgroundTasksStatsResponse(BaseModel):
"""Statistics for background tasks"""
total_tasks: int
running: int
completed: int
failed: int
tasks_today: int
avg_duration_seconds: float | None
# By type
import_jobs: dict
test_runs: dict
code_quality_scans: dict
def _convert_import_to_response(job) -> BackgroundTaskResponse:
"""Convert MarketplaceImportJob to BackgroundTaskResponse"""
duration = None
if job.started_at and job.completed_at:
duration = (job.completed_at - job.started_at).total_seconds()
elif job.started_at and job.status == "processing":
duration = (datetime.now(UTC) - job.started_at).total_seconds()
return BackgroundTaskResponse(
id=job.id,
task_type="import",
status=job.status,
started_at=job.started_at.isoformat() if job.started_at else None,
completed_at=job.completed_at.isoformat() if job.completed_at else None,
duration_seconds=duration,
description=f"Import from {job.marketplace}: {job.source_url[:50]}..."
if len(job.source_url) > 50
else f"Import from {job.marketplace}: {job.source_url}",
triggered_by=job.user.username if job.user else None,
error_message=job.error_message,
details={
"marketplace": job.marketplace,
"vendor_id": job.vendor_id,
"imported": job.imported_count,
"updated": job.updated_count,
"errors": job.error_count,
"total_processed": job.total_processed,
},
celery_task_id=getattr(job, "celery_task_id", None),
)
def _convert_test_run_to_response(run) -> BackgroundTaskResponse:
"""Convert TestRun to BackgroundTaskResponse"""
duration = run.duration_seconds
if run.status == "running" and run.timestamp:
duration = (datetime.now(UTC) - run.timestamp).total_seconds()
return BackgroundTaskResponse(
id=run.id,
task_type="test_run",
status=run.status,
started_at=run.timestamp.isoformat() if run.timestamp else None,
completed_at=None,
duration_seconds=duration,
description=f"Test run: {run.test_path}",
triggered_by=run.triggered_by,
error_message=None,
details={
"test_path": run.test_path,
"total_tests": run.total_tests,
"passed": run.passed,
"failed": run.failed,
"errors": run.errors,
"pass_rate": run.pass_rate,
"git_branch": run.git_branch,
},
celery_task_id=getattr(run, "celery_task_id", None),
)
def _convert_scan_to_response(scan) -> BackgroundTaskResponse:
"""Convert ArchitectureScan to BackgroundTaskResponse"""
duration = scan.duration_seconds
if scan.status in ["pending", "running"] and scan.started_at:
duration = (datetime.now(UTC) - scan.started_at).total_seconds()
# Map validator type to human-readable name
validator_names = {
"architecture": "Architecture",
"security": "Security",
"performance": "Performance",
}
validator_name = validator_names.get(scan.validator_type, scan.validator_type)
return BackgroundTaskResponse(
id=scan.id,
task_type="code_quality_scan",
status=scan.status,
started_at=scan.started_at.isoformat() if scan.started_at else None,
completed_at=scan.completed_at.isoformat() if scan.completed_at else None,
duration_seconds=duration,
description=f"{validator_name} code quality scan",
triggered_by=scan.triggered_by,
error_message=scan.error_message,
details={
"validator_type": scan.validator_type,
"total_files": scan.total_files,
"total_violations": scan.total_violations,
"errors": scan.errors,
"warnings": scan.warnings,
"git_commit_hash": scan.git_commit_hash,
"progress_message": scan.progress_message,
},
celery_task_id=getattr(scan, "celery_task_id", None),
)
@admin_tasks_router.get("", response_model=list[BackgroundTaskResponse])
async def list_background_tasks(
status: str | None = Query(None, description="Filter by status"),
task_type: str | None = Query(
None, description="Filter by type (import, test_run, code_quality_scan)"
),
limit: int = Query(50, ge=1, le=200),
db: Session = Depends(get_db),
current_user: UserContext = Depends(get_current_admin_api),
):
"""
List all background tasks across the system
Returns a unified view of import jobs, test runs, and code quality scans.
"""
tasks = []
# Get import jobs
if task_type is None or task_type == "import":
import_jobs = background_tasks_service.get_import_jobs(
db, status=status, limit=limit
)
tasks.extend([_convert_import_to_response(job) for job in import_jobs])
# Get test runs
if task_type is None or task_type == "test_run":
test_runs = background_tasks_service.get_test_runs(
db, status=status, limit=limit
)
tasks.extend([_convert_test_run_to_response(run) for run in test_runs])
# Get code quality scans
if task_type is None or task_type == "code_quality_scan":
scans = background_tasks_service.get_code_quality_scans(
db, status=status, limit=limit
)
tasks.extend([_convert_scan_to_response(scan) for scan in scans])
# Sort by start time (most recent first)
tasks.sort(
key=lambda t: t.started_at or "1970-01-01T00:00:00",
reverse=True,
)
return tasks[:limit]
@admin_tasks_router.get("/stats", response_model=BackgroundTasksStatsResponse)
async def get_background_tasks_stats(
db: Session = Depends(get_db),
current_user: UserContext = Depends(get_current_admin_api),
):
"""
Get statistics for background tasks
"""
import_stats = background_tasks_service.get_import_stats(db)
test_stats = background_tasks_service.get_test_run_stats(db)
scan_stats = background_tasks_service.get_scan_stats(db)
# Combined stats
total_running = (
import_stats["running"] + test_stats["running"] + scan_stats["running"]
)
total_completed = (
import_stats["completed"] + test_stats["completed"] + scan_stats["completed"]
)
total_failed = (
import_stats["failed"] + test_stats["failed"] + scan_stats["failed"]
)
total_tasks = import_stats["total"] + test_stats["total"] + scan_stats["total"]
tasks_today = import_stats["today"] + test_stats["today"] + scan_stats["today"]
return BackgroundTasksStatsResponse(
total_tasks=total_tasks,
running=total_running,
completed=total_completed,
failed=total_failed,
tasks_today=tasks_today,
avg_duration_seconds=test_stats.get("avg_duration"),
import_jobs=import_stats,
test_runs=test_stats,
code_quality_scans=scan_stats,
)
@admin_tasks_router.get("/running", response_model=list[BackgroundTaskResponse])
async def list_running_tasks(
db: Session = Depends(get_db),
current_user: UserContext = Depends(get_current_admin_api),
):
"""
List currently running background tasks
"""
tasks = []
# Running imports
running_imports = background_tasks_service.get_running_imports(db)
tasks.extend([_convert_import_to_response(job) for job in running_imports])
# Running test runs
running_tests = background_tasks_service.get_running_test_runs(db)
tasks.extend([_convert_test_run_to_response(run) for run in running_tests])
# Running code quality scans
running_scans = background_tasks_service.get_running_scans(db)
tasks.extend([_convert_scan_to_response(scan) for scan in running_scans])
return tasks

View File

@@ -0,0 +1,338 @@
# app/modules/monitoring/routes/api/admin_tests.py
"""
Test Runner API Endpoints
RESTful API for running pytest and viewing test results
"""
from fastapi import APIRouter, BackgroundTasks, 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.modules.dev_tools.services.test_runner_service import test_runner_service
from models.schema.auth import UserContext
admin_tests_router = APIRouter(prefix="/tests")
# Pydantic Models for API
class TestRunResponse(BaseModel):
"""Response model for a test run"""
id: int
timestamp: str
total_tests: int
passed: int
failed: int
errors: int
skipped: int
xfailed: int
xpassed: int
pass_rate: float
duration_seconds: float
coverage_percent: float | None
triggered_by: str | None
git_commit_hash: str | None
git_branch: str | None
test_path: str | None
status: str
class Config:
from_attributes = True
class TestResultResponse(BaseModel):
"""Response model for a single test result"""
id: int
node_id: str
test_name: str
test_file: str
test_class: str | None
outcome: str
duration_seconds: float
error_message: str | None
traceback: str | None
class Config:
from_attributes = True
class RunTestsRequest(BaseModel):
"""Request model for running tests"""
test_path: str = Field("tests", description="Path to tests to run")
extra_args: list[str] | None = Field(
None, description="Additional pytest arguments"
)
class TestDashboardStatsResponse(BaseModel):
"""Response model for dashboard statistics"""
# Current run stats
total_tests: int
passed: int
failed: int
errors: int
skipped: int
pass_rate: float
duration_seconds: float
coverage_percent: float | None
last_run: str | None
last_run_status: str | None
# Collection stats
total_test_files: int
collected_tests: int
unit_tests: int
integration_tests: int
performance_tests: int
system_tests: int
last_collected: str | None
# Trend and breakdown data
trend: list[dict]
by_category: dict
top_failing: list[dict]
# API Endpoints
@admin_tests_router.post("/run", response_model=TestRunResponse)
async def run_tests(
background_tasks: BackgroundTasks,
request: RunTestsRequest | None = None,
db: Session = Depends(get_db),
current_user: UserContext = Depends(get_current_admin_api),
):
"""
Start a pytest run in the background
Requires admin authentication. Creates a test run record and starts
pytest execution in the background. Returns immediately with the run ID.
Poll GET /runs/{run_id} to check status.
"""
test_path = request.test_path if request else "tests"
extra_args = request.extra_args if request else None
# Create the test run record
run = test_runner_service.create_test_run(
db,
test_path=test_path,
triggered_by=f"manual:{current_user.username}",
)
db.commit()
# Dispatch via task dispatcher (supports Celery or BackgroundTasks)
from app.tasks.dispatcher import task_dispatcher
celery_task_id = task_dispatcher.dispatch_test_run(
background_tasks=background_tasks,
run_id=run.id,
test_path=test_path,
extra_args=extra_args,
)
# Store Celery task ID if using Celery
if celery_task_id:
run.celery_task_id = celery_task_id
db.commit()
return TestRunResponse(
id=run.id,
timestamp=run.timestamp.isoformat(),
total_tests=run.total_tests,
passed=run.passed,
failed=run.failed,
errors=run.errors,
skipped=run.skipped,
xfailed=run.xfailed,
xpassed=run.xpassed,
pass_rate=run.pass_rate,
duration_seconds=run.duration_seconds,
coverage_percent=run.coverage_percent,
triggered_by=run.triggered_by,
git_commit_hash=run.git_commit_hash,
git_branch=run.git_branch,
test_path=run.test_path,
status=run.status,
)
@admin_tests_router.get("/runs", response_model=list[TestRunResponse])
async def list_runs(
limit: int = Query(20, ge=1, le=100, description="Number of runs to return"),
db: Session = Depends(get_db),
current_user: UserContext = Depends(get_current_admin_api),
):
"""
Get test run history
Returns recent test runs for trend analysis.
"""
runs = test_runner_service.get_run_history(db, limit=limit)
return [
TestRunResponse(
id=run.id,
timestamp=run.timestamp.isoformat(),
total_tests=run.total_tests,
passed=run.passed,
failed=run.failed,
errors=run.errors,
skipped=run.skipped,
xfailed=run.xfailed,
xpassed=run.xpassed,
pass_rate=run.pass_rate,
duration_seconds=run.duration_seconds,
coverage_percent=run.coverage_percent,
triggered_by=run.triggered_by,
git_commit_hash=run.git_commit_hash,
git_branch=run.git_branch,
test_path=run.test_path,
status=run.status,
)
for run in runs
]
@admin_tests_router.get("/runs/{run_id}", response_model=TestRunResponse)
async def get_run(
run_id: int,
db: Session = Depends(get_db),
current_user: UserContext = Depends(get_current_admin_api),
):
"""
Get a specific test run
"""
run = test_runner_service.get_run_by_id(db, run_id)
if not run:
from app.exceptions.base import ResourceNotFoundException
raise ResourceNotFoundException("TestRun", str(run_id))
return TestRunResponse(
id=run.id,
timestamp=run.timestamp.isoformat(),
total_tests=run.total_tests,
passed=run.passed,
failed=run.failed,
errors=run.errors,
skipped=run.skipped,
xfailed=run.xfailed,
xpassed=run.xpassed,
pass_rate=run.pass_rate,
duration_seconds=run.duration_seconds,
coverage_percent=run.coverage_percent,
triggered_by=run.triggered_by,
git_commit_hash=run.git_commit_hash,
git_branch=run.git_branch,
test_path=run.test_path,
status=run.status,
)
@admin_tests_router.get("/runs/{run_id}/results", response_model=list[TestResultResponse])
async def get_run_results(
run_id: int,
outcome: str | None = Query(
None, description="Filter by outcome (passed, failed, error, skipped)"
),
db: Session = Depends(get_db),
current_user: UserContext = Depends(get_current_admin_api),
):
"""
Get test results for a specific run
"""
results = test_runner_service.get_run_results(db, run_id, outcome=outcome)
return [
TestResultResponse(
id=r.id,
node_id=r.node_id,
test_name=r.test_name,
test_file=r.test_file,
test_class=r.test_class,
outcome=r.outcome,
duration_seconds=r.duration_seconds,
error_message=r.error_message,
traceback=r.traceback,
)
for r in results
]
@admin_tests_router.get("/runs/{run_id}/failures", response_model=list[TestResultResponse])
async def get_run_failures(
run_id: int,
db: Session = Depends(get_db),
current_user: UserContext = Depends(get_current_admin_api),
):
"""
Get failed tests from a specific run
"""
failures = test_runner_service.get_failed_tests(db, run_id)
return [
TestResultResponse(
id=r.id,
node_id=r.node_id,
test_name=r.test_name,
test_file=r.test_file,
test_class=r.test_class,
outcome=r.outcome,
duration_seconds=r.duration_seconds,
error_message=r.error_message,
traceback=r.traceback,
)
for r in failures
]
@admin_tests_router.get("/stats", response_model=TestDashboardStatsResponse)
async def get_dashboard_stats(
db: Session = Depends(get_db),
current_user: UserContext = Depends(get_current_admin_api),
):
"""
Get dashboard statistics
Returns comprehensive stats for the testing dashboard including:
- Total counts by outcome
- Pass rate
- Trend data
- Tests by category
- Top failing tests
"""
stats = test_runner_service.get_dashboard_stats(db)
return TestDashboardStatsResponse(**stats)
@admin_tests_router.post("/collect")
async def collect_tests(
db: Session = Depends(get_db),
current_user: UserContext = Depends(get_current_admin_api),
):
"""
Collect test information without running tests
Updates the test collection cache with current test counts.
"""
collection = test_runner_service.collect_tests(db)
db.commit()
return {
"total_tests": collection.total_tests,
"total_files": collection.total_files,
"unit_tests": collection.unit_tests,
"integration_tests": collection.integration_tests,
"performance_tests": collection.performance_tests,
"system_tests": collection.system_tests,
"collected_at": collection.collected_at.isoformat(),
}