#!/usr/bin/env python3 """ HTML Report Generator Generates a clean, professional HTML report from JSON report data with search and filter capabilities. """ import json from pathlib import Path from datetime import datetime from typing import Dict, List, Optional def format_date(date_str: Optional[str]) -> str: """Format date string for display.""" if not date_str: return "N/A" try: # Try parsing various formats if "/" in date_str: # MM/DD/YY or MM/DD/YYYY parts = date_str.split("/") if len(parts) == 3: if len(parts[2]) == 2: # Convert YY to YYYY parts[2] = "20" + parts[2] return "/".join(parts) # Already formatted or ISO format return date_str.split()[0] if " " in date_str else date_str except: return date_str def get_status_badge_class(status: str) -> str: """Get CSS class for status badge.""" status_lower = status.lower().strip() # Check for incomplete FIRST (before checking for "complete" substring) # because "incomplete" contains "complete" as a substring! if "incomplete" in status_lower: return "badge-warning" # Yellow/orange like Monitor elif "complete" in status_lower: return "badge-success" # Green elif "monitor" in status_lower: return "badge-warning" # Yellow/orange else: return "badge-secondary" def get_priority_badge_class(priority: Optional[str]) -> str: """Get CSS class for priority badge.""" if not priority: return "badge-secondary" priority_lower = priority.lower() if "critical" in priority_lower or "very high" in priority_lower or "(1)" in priority_lower: return "badge-critical" elif "high" in priority_lower or "(2)" in priority_lower: return "badge-high" elif "medium" in priority_lower or "(3)" in priority_lower: return "badge-medium" elif "low" in priority_lower or "(4)" in priority_lower: return "badge-low" else: return "badge-secondary" def escape_js_string(s: str) -> str: """Escape a string for use in JavaScript double-quoted strings.""" return s.replace('\\', '\\\\').replace('"', '\\"').replace('\n', '\\n').replace('\r', '\\r') def generate_html_report(json_path: str, output_path: Optional[str] = None) -> str: """ Generate HTML report from JSON report file. Args: json_path: Path to JSON report file output_path: Optional output HTML file path (defaults to same location as JSON with .html extension) Returns: Path to generated HTML file """ # Load JSON report with open(json_path, 'r', encoding='utf-8') as f: report_data = json.load(f) # Determine output path if output_path is None: json_file = Path(json_path) output_path = json_file.parent / f"{json_file.stem}.html" else: output_path = Path(output_path) # Generate HTML html_content = generate_html_content(report_data) # Save HTML file output_path.parent.mkdir(parents=True, exist_ok=True) with open(output_path, 'w', encoding='utf-8') as f: f.write(html_content) return str(output_path) def generate_html_content(report_data: Dict) -> str: """Generate HTML content from report data.""" vendors = report_data.get('vendors', []) summary = report_data.get('summary', {}) report_generated_at = report_data.get('report_generated_at', datetime.now().isoformat()) # Format generation time try: gen_time = datetime.fromisoformat(report_generated_at.replace('Z', '+00:00')) gen_time_str = gen_time.strftime('%Y-%m-%d %H:%M:%S') except: gen_time_str = report_generated_at # Extract vendor names for filter vendor_names = sorted([v.get('vendor_name', '') for v in vendors if v.get('vendor_name')]) html = rf""" Vendor Punchlist Report

Vendor Punchlist Report

Generated: {gen_time_str} | Total Vendors: {summary.get('total_vendors', 0)} | Total Items: {summary.get('total_items', 0)}
Filters & Search
Quick Filters

{summary.get('total_vendors', 0)}

Vendors

{summary.get('total_items', 0)}

Total Items

{summary.get('total_closed', 0)}

Closed

{summary.get('total_monitor', 0)}

Monitor

{summary.get('total_incomplete', 0)}

Incomplete

{''.join(['' for vn in vendor_names])}
{generate_vendor_sections(vendors)}
""" return html def generate_vendor_sections(vendors: List[Dict]) -> str: """Generate HTML for all vendor sections.""" sections = [] for vendor in vendors: sections.append(generate_vendor_section(vendor)) return "\n".join(sections) def generate_vendor_section(vendor: Dict) -> str: """Generate HTML for a single vendor section.""" vendor_name = vendor.get('vendor_name', 'Unknown') total_items = vendor.get('total_items', 0) closed_count = vendor.get('closed_count', 0) monitor_count = vendor.get('monitor_count', 0) incomplete_count = vendor.get('incomplete_count', 0) updates_24h = vendor.get('updates_24h', {}) oldest_unaddressed = vendor.get('oldest_unaddressed', []) critical_items = vendor.get('critical_priority_items', []) high_items = vendor.get('high_priority_items', []) # Use the pre-populated status-grouped items from report_generator # These contain ALL items, not just priority/oldest subsets closed_items = vendor.get('closed_items', []) monitor_items = vendor.get('monitor_items', []) incomplete_items = vendor.get('incomplete_items', []) # Convert to lists if needed (they should already be lists) if not isinstance(closed_items, list): closed_items = [] if not isinstance(monitor_items, list): monitor_items = [] if not isinstance(incomplete_items, list): incomplete_items = [] # Group all items by priority for the "All" tab # Combine all items first with deduplication # Since we're already within a vendor section, deduplicate by punchlist_name only all_items_combined = [] seen_names = set() # Track by normalized punchlist_name for deduplication # Add all closed items for item in closed_items: name = item.get('punchlist_name', '').strip().lower() if name and name not in seen_names: seen_names.add(name) all_items_combined.append(item) # Add all monitor items for item in monitor_items: name = item.get('punchlist_name', '').strip().lower() if name and name not in seen_names: seen_names.add(name) all_items_combined.append(item) # Add all incomplete items for item in incomplete_items: name = item.get('punchlist_name', '').strip().lower() if name and name not in seen_names: seen_names.add(name) all_items_combined.append(item) # Group items by priority level critical_all = [] high_all = [] medium_all = [] low_all = [] other_priority_all = [] no_priority_all = [] for item in all_items_combined: priority = item.get('priority', '') or '' priority_lower = priority.lower() if 'critical' in priority_lower or 'very high' in priority_lower or '(1)' in priority_lower or 'very hgh' in priority_lower: critical_all.append(item) elif ('high' in priority_lower and 'very' not in priority_lower and 'critical' not in priority_lower) or '(2)' in priority_lower: high_all.append(item) elif 'medium' in priority_lower or '(3)' in priority_lower: medium_all.append(item) elif 'low' in priority_lower or '(4)' in priority_lower: low_all.append(item) elif priority.strip(): other_priority_all.append(item) else: no_priority_all.append(item) html = f"""
{vendor_name}
{total_items}
Total
{closed_count}
Closed
{monitor_count}
Monitor
{incomplete_count}
Incomplete
{generate_priority_items_section('Critical Priority', critical_all)} {generate_priority_items_section('High Priority', high_all)} {generate_priority_items_section('Medium Priority', medium_all)} {generate_priority_items_section('Low Priority', low_all)} {generate_priority_items_section('Other Priority', other_priority_all) if other_priority_all else ''} {generate_priority_items_section('No Priority', no_priority_all) if no_priority_all else ''} {'' if (critical_all or high_all or medium_all or low_all or other_priority_all or no_priority_all) else '
All Items
  • No items
'}
{generate_24h_updates_section(updates_24h, vendor_name)}
{generate_oldest_unaddressed_section(oldest_unaddressed)}
Closed Items ({len(closed_items)})
    {''.join([generate_item_html(item) for item in closed_items]) if closed_items else '
  • No closed items
  • '}
Monitor Items ({len(monitor_items)})
    {''.join([generate_item_html(item) for item in monitor_items]) if monitor_items else '
  • No monitor items
  • '}
Incomplete Items ({len(incomplete_items)})
    {''.join([generate_item_html(item) for item in incomplete_items]) if incomplete_items else '
  • No incomplete items
  • '}
""" return html def generate_24h_updates_section(updates_24h: Dict, vendor_name: str = '') -> str: """Generate HTML for yesterday's updates section with sub-tabs.""" added = updates_24h.get('added', []) closed = updates_24h.get('closed', []) changed_to_monitor = updates_24h.get('changed_to_monitor', []) total_updates = len(added) + len(closed) + len(changed_to_monitor) if total_updates == 0: return """
Yesterday's Updates
""" # Generate unique ID for this vendor's update tabs vendor_name_safe = vendor_name.replace('/', '_').replace(' ', '_').replace('(', '').replace(')', '') html = f"""
Yesterday's Updates ({total_updates})
Items Added Yesterday ({len(added)})
    {''.join([generate_item_html(item) for item in added]) if added else '
  • No items added yesterday
  • '}
Items Closed Yesterday ({len(closed)})
    {''.join([generate_item_html(item) for item in closed]) if closed else '
  • No items closed yesterday
  • '}
Items Changed to Monitor Yesterday ({len(changed_to_monitor)})
    {''.join([generate_item_html(item) for item in changed_to_monitor]) if changed_to_monitor else '
  • No items changed to monitor yesterday
  • '}
""" return html def generate_oldest_unaddressed_section(items: List[Dict]) -> str: """Generate HTML for oldest unaddressed items section.""" if not items: return """
Oldest 3 Unaddressed Items
""" html = f"""
Oldest 3 Unaddressed Items ({len(items)})
""" return html def generate_priority_items_section(title: str, items: List[Dict]) -> str: """Generate HTML for priority items section.""" if not items: return "" html = f"""
{title} ({len(items)} items)
""" return html def generate_item_html(item: Dict, show_age: bool = False) -> str: """Generate HTML for a single item.""" name = item.get('punchlist_name', 'Unknown') description = item.get('description', '') priority = item.get('priority', '') status = item.get('status', 'Unknown') date_identified = item.get('date_identified', '') date_completed = item.get('date_completed', '') status_updates = item.get('status_updates', '') issue_image = item.get('issue_image', '') age_days = item.get('age_days') status_class = get_status_badge_class(status) priority_class = get_priority_badge_class(priority) html = f"""
  • {name}
    {status} {f'{priority}' if priority else ''} {f'{age_days} days' if show_age and age_days is not None else ''}
    {f'

    Description: {description}

    ' if description else ''} {f'

    Date Identified: {format_date(date_identified)}

    ' if date_identified else ''} {f'

    Date Completed: {format_date(date_completed)}

    ' if date_completed else ''} {f'

    Status Updates: {status_updates}

    ' if status_updates else ''} {f'

    Image: {issue_image}

    ' if issue_image else ''}
  • """ return html if __name__ == "__main__": import argparse parser = argparse.ArgumentParser(description="Generate HTML report from JSON") parser.add_argument( "--json", type=str, required=True, help="Path to JSON report file" ) parser.add_argument( "--output", type=str, default=None, help="Output HTML file path (defaults to same location as JSON with .html extension)" ) args = parser.parse_args() output_path = generate_html_report(args.json, args.output) print(f"✓ HTML report generated: {output_path}")