254 lines
9.9 KiB
Python
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])
|