hm_video_ai_qc_tool/utils/report.py
2025-12-31 12:59:50 +02:00

254 lines
9.9 KiB
Python

"""
HTML Report Generator for Video QC Results.
Generates Bootstrap-based HTML reports with collapsible check sections,
color-coded status badges, and detailed results.
"""
import os
import json
import sys
from datetime import datetime
# Add parent directory to path for config import
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import config
class HTMLReporter:
@staticmethod
def generate_report(json_data: dict, reports_dir: str, input_filename: str) -> str:
"""
Generate HTML QC report from JSON results.
:param json_data: QC results in JSON format
:param reports_dir: Directory to save the report
:param input_filename: Name of the input video file
:return: Path to generated HTML report
"""
try:
# Create directory if needed
os.makedirs(reports_dir, exist_ok=True)
# Generate filename
date_str = datetime.now().isoformat(timespec='seconds').replace(":", "-")
safe_name = (input_filename.rpartition('.')[0].replace('.', '_') + ('.' + input_filename.rpartition('.')[2] if input_filename.rpartition('.')[1] else input_filename)).replace(" ", "_").replace(":", "-").split('.')[0]
report_filename = f"{safe_name}_{date_str}_VideoQC.html"
output_path = os.path.join(reports_dir, report_filename)
# Generate HTML content
html_content = HTMLReporter._build_html_template(json_data, input_filename)
# Write to file
with open(output_path, 'w', encoding='utf-8') as f:
f.write(html_content)
# Write to environment-aware generic reports dir
os.makedirs(config.REPORTS_DIR, exist_ok=True)
report_path = os.path.join(config.REPORTS_DIR, report_filename)
with open(report_path, 'w', encoding='utf-8') as f:
f.write(html_content)
return output_path
except Exception as e:
raise RuntimeError(f"HTML report generation failed: {str(e)}") from e
@staticmethod
def _build_html_template(json_data: dict, input_filename: str) -> str:
"""Construct the full HTML template with data"""
return f'''
<!DOCTYPE html>
<html lang="en">
{HTMLReporter._build_head(input_filename, json_data['timestamp'])}
<body>
<div class="container py-4">
{HTMLReporter._build_header(input_filename, json_data['timestamp'])}
{HTMLReporter._build_summary(json_data['checks'])}
<div class="accordion" id="checksAccordion">
{''.join(HTMLReporter._generate_check_html(check) for check in json_data['checks'])}
</div>
</div>
{HTMLReporter._build_scripts()}
</body>
</html>
'''
@staticmethod
def _build_head(filename: str, timestamp: str) -> str:
"""Build the HTML head section"""
return f'''
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Video QC Report - {filename}</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet">
<style>
{HTMLReporter._get_css_styles()}
</style>
</head>
'''
@staticmethod
def _get_css_styles() -> str:
"""Return CSS styles"""
return '''
.status-badge { font-size: 0.8rem; padding: 0.35em 0.65em; }
.check-card { margin-bottom: 1rem; }
.details-list { list-style-type: none; padding-left: 0; margin-bottom: 0; }
.details-list li { margin-bottom: 0.75rem; line-height: 1.6; }
.nested-details { margin-top: 0.5rem; margin-left: 0; padding-left: 1rem; border-left: 3px solid #667eea; }
.nested-details .details-list { padding-left: 0.5rem; }
.nested-details li { margin-bottom: 0.5rem; }
.error-section { background-color: #fff3cd; border-radius: 4px; padding: 1rem; margin: 1rem 0; }
.summary-card { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; border-radius: 10px; padding: 2rem; margin-bottom: 2rem; }
.summary-stats { display: flex; justify-content: space-around; margin-top: 1rem; }
.stat-item { text-align: center; }
.stat-number { font-size: 2rem; font-weight: bold; }
.stat-label { font-size: 0.9rem; opacity: 0.9; }
'''
@staticmethod
def _build_header(filename: str, timestamp: str) -> str:
"""Build the header section"""
return f'''
<header class="mb-4">
<h1 class="display-4">🎬 Video QC Report</h1>
<h3>{filename}</h3>
<p class="text-muted">Generated at: {datetime.fromisoformat(timestamp.rstrip('Z')).strftime('%Y-%m-%d %H:%M:%S')}</p>
</header>
'''
@staticmethod
def _build_summary(checks: list) -> str:
"""Build summary statistics card"""
passed = sum(1 for c in checks if c['result']['status'].lower() == 'passed')
failed = sum(1 for c in checks if c['result']['status'].lower() == 'error')
skipped = sum(1 for c in checks if c['result']['status'].lower() == 'skipped')
total = len(checks)
return f'''
<div class="summary-card">
<h3>Summary</h3>
<div class="summary-stats">
<div class="stat-item">
<div class="stat-number">{total}</div>
<div class="stat-label">Total Checks</div>
</div>
<div class="stat-item">
<div class="stat-number">{passed}</div>
<div class="stat-label">✓ Passed</div>
</div>
<div class="stat-item">
<div class="stat-number">{failed}</div>
<div class="stat-label">✗ Failed</div>
</div>
<div class="stat-item">
<div class="stat-number">{skipped}</div>
<div class="stat-label">⊘ Skipped</div>
</div>
</div>
</div>
'''
@staticmethod
def _build_scripts() -> str:
"""Include required JavaScript"""
return '''
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js"></script>
'''
@staticmethod
def _generate_check_html(check: dict) -> str:
"""Generate HTML for an individual check"""
status_color = {
'passed': 'success',
'error': 'danger',
'failed': 'warning',
'skipped': 'secondary'
}.get(check['result']['status'].lower(), 'secondary')
return f'''
<div class="accordion-item check-card">
<h2 class="accordion-header" id="heading{check['index']}">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse"
data-bs-target="#collapse{check['index']}" aria-expanded="false"
aria-controls="collapse{check['index']}">
<span class="badge bg-{status_color} status-badge me-2">{check['result']['status'].upper()}</span>
{check['id']}: {check['config'].get('description', 'No description')}
</button>
</h2>
<div id="collapse{check['index']}" class="accordion-collapse collapse"
aria-labelledby="heading{check['index']}" data-bs-parent="#checksAccordion">
<div class="accordion-body">
{HTMLReporter._generate_error_section(check['result'])}
<h5>Configuration</h5>
<ul class="details-list">
{HTMLReporter._format_details(check['config'])}
</ul>
<h5>Results</h5>
<ul class="details-list">
{HTMLReporter._format_details(check['result'].get('details', {}))}
</ul>
</div>
</div>
</div>
'''
@staticmethod
def _generate_error_section(result: dict) -> str:
"""Generate error section if present"""
if 'error_message' not in result:
return ''
return f'''
<div class="error-section">
<h5 class="text-danger">Error:</h5>
<p>{result['error_message']}</p>
{HTMLReporter._format_details(result.get('details', {}))}
</div>
'''
@staticmethod
def _format_details(details: dict, level: int = 0) -> str:
"""Recursively format nested details"""
items = []
for key, value in details.items():
if key == 'error_message':
continue
if isinstance(value, dict):
items.append(f'''
<li>
<strong>{key.title()}:</strong>
<div class="nested-details">
<ul class="details-list">
{HTMLReporter._format_details(value, level+1)}
</ul>
</div>
</li>
''')
elif isinstance(value, list):
list_items = ''.join(f'<li>{item}</li>' for item in value)
items.append(f'''
<li>
<strong>{key.title()}:</strong>
<ul>{list_items}</ul>
</li>
''')
else:
items.append(f'<li><strong>{key.title()}:</strong> {value}</li>')
return '\n'.join(items)
if __name__ == "__main__":
# Example usage
import sys
if len(sys.argv) != 3:
print("Usage: python report.py <input_json> <output_html>")
sys.exit(1)
with open(sys.argv[1]) as f:
data = json.load(f)
HTMLReporter.generate_report(data, sys.argv[2])