#!/usr/bin/env python3 """ Web API Server for On-Demand Report Generation Provides REST API endpoints to trigger report generation on demand. """ import logging from pathlib import Path from typing import Optional, List, Dict import json from datetime import datetime import shutil import os try: from flask import Flask, jsonify, request, send_from_directory from flask_cors import CORS from werkzeug.utils import secure_filename FLASK_AVAILABLE = True except ImportError: FLASK_AVAILABLE = False logging.warning("Flask not installed. API server features disabled.") from config import load_config from report_generator import generate_report from sharepoint_downloader import download_from_sharepoint logger = logging.getLogger(__name__) app = None config = None scheduler_thread = None def cleanup_old_reports(output_dir: Path, reports_dir: Path, max_reports: int = 10): """ Cleanup old reports and Excel files, keeping only the last max_reports. Args: output_dir: Directory containing report HTML/JSON files reports_dir: Directory containing Excel files max_reports: Maximum number of reports to keep """ try: # Get all report HTML files sorted by modification time (newest first) html_files = sorted(output_dir.glob('report-*.html'), key=lambda p: p.stat().st_mtime, reverse=True) if len(html_files) <= max_reports: return # No cleanup needed # Get reports to delete (oldest ones) reports_to_delete = html_files[max_reports:] deleted_count = 0 for html_file in reports_to_delete: report_id = html_file.stem # Delete HTML file try: html_file.unlink() logger.info(f"Deleted old report HTML: {html_file.name}") deleted_count += 1 except Exception as e: logger.warning(f"Failed to delete {html_file.name}: {e}") # Delete corresponding JSON file json_file = output_dir / f"{report_id}.json" if json_file.exists(): try: json_file.unlink() logger.info(f"Deleted old report JSON: {json_file.name}") except Exception as e: logger.warning(f"Failed to delete {json_file.name}: {e}") # Cleanup Excel files - keep only files associated with remaining reports if reports_dir.exists(): excel_files = list(reports_dir.glob('*.xlsx')) + list(reports_dir.glob('*.xls')) if len(excel_files) > max_reports: # Sort by modification time and delete oldest excel_files_sorted = sorted(excel_files, key=lambda p: p.stat().st_mtime, reverse=True) excel_to_delete = excel_files_sorted[max_reports:] for excel_file in excel_to_delete: try: excel_file.unlink() logger.info(f"Deleted old Excel file: {excel_file.name}") except Exception as e: logger.warning(f"Failed to delete {excel_file.name}: {e}") logger.info(f"Cleanup completed: deleted {deleted_count} old report(s)") except Exception as e: logger.error(f"Error during cleanup: {e}", exc_info=True) def create_app(config_path: Optional[str] = None): """Create and configure Flask app.""" global app, config if not FLASK_AVAILABLE: raise ImportError( "Flask is required for API server. " "Install it with: pip install flask flask-cors" ) app = Flask(__name__) CORS(app) # Enable CORS for all routes config = load_config(config_path) api_config = config.get('api', {}) sharepoint_config = config.get('sharepoint', {}) report_config = config.get('report', {}) # Resolve paths relative to script location, not current working directory script_dir = Path(__file__).parent.absolute() # Convert relative paths to absolute paths relative to script directory if 'output_dir' in report_config and report_config['output_dir']: output_dir = Path(report_config['output_dir']) if not output_dir.is_absolute(): report_config['output_dir'] = str(script_dir / output_dir) if 'reports_dir' in report_config and report_config['reports_dir']: reports_dir = Path(report_config['reports_dir']) if not reports_dir.is_absolute(): report_config['reports_dir'] = str(script_dir / reports_dir) # Store config in app context app.config['API_KEY'] = api_config.get('api_key') app.config['SHAREPOINT_CONFIG'] = sharepoint_config app.config['REPORT_CONFIG'] = report_config @app.route('/health', methods=['GET']) def health(): """Health check endpoint.""" return jsonify({ 'status': 'healthy', 'service': 'vendor-report-generator' }) @app.route('/api/generate', methods=['POST']) def generate_report_endpoint(): """ Generate report on demand. Request body (optional): { "download_from_sharepoint": true, "reports_dir": "reports", "output_file": "output/report.json" } """ # Check API key if configured api_key = app.config.get('API_KEY') if api_key: provided_key = request.headers.get('X-API-Key') or request.json.get('api_key') if request.json else None if provided_key != api_key: return jsonify({'error': 'Invalid API key'}), 401 try: request_data = request.json or {} download_from_sp = request_data.get('download_from_sharepoint', True) # Default to True for backward compatibility downloaded_files = [] # Initialize here for scope # Get report config early - needed for error handling report_config = app.config['REPORT_CONFIG'] # Download from SharePoint if requested AND no manual upload happened # If download_from_sharepoint is False, it means manual upload was used if download_from_sp: sp_config = app.config['SHAREPOINT_CONFIG'] if not sp_config.get('enabled'): return jsonify({ 'error': 'SharePoint is not enabled in configuration' }), 400 logger.info("Downloading files from SharePoint...") try: downloaded = download_from_sharepoint( site_url=sp_config['site_url'], folder_path=sp_config.get('folder_path'), file_path=sp_config.get('file_path'), local_dir=sp_config.get('local_dir', 'reports'), tenant_id=sp_config.get('tenant_id'), client_id=sp_config.get('client_id'), client_secret=sp_config.get('client_secret'), use_app_authentication=sp_config.get('use_app_authentication', True), file_pattern=sp_config.get('file_pattern'), overwrite=sp_config.get('overwrite', True) ) downloaded_files = downloaded if downloaded else [] logger.info(f"Downloaded {len(downloaded_files)} file(s) from SharePoint: {downloaded_files}") # If SharePoint download failed (no files downloaded), check if we have existing files if len(downloaded_files) == 0: logger.warning("SharePoint download returned 0 files. This could mean:") logger.warning("1. SharePoint permissions issue (401/403 error)") logger.warning("2. No files found in the specified folder") logger.warning("3. Site access not granted (Resource-Specific Consent needed)") logger.warning("Checking if existing files are available in reports directory...") # Check if there are existing files we can use reports_dir_path = Path(report_config.get('reports_dir', 'reports')) if not reports_dir_path.is_absolute(): script_dir = Path(__file__).parent.absolute() reports_dir_path = script_dir / reports_dir_path if reports_dir_path.exists(): existing_files = list(reports_dir_path.glob('*.xlsx')) + list(reports_dir_path.glob('*.xls')) if existing_files: logger.warning(f"Found {len(existing_files)} existing file(s) in reports directory. Will use these instead.") logger.warning("NOTE: These may be old files. Consider using manual upload for fresh data.") else: logger.error("No files available - neither from SharePoint nor existing files.") return jsonify({ 'error': 'SharePoint download failed and no existing files found', 'details': 'SharePoint access may require Resource-Specific Consent (RSC). Please use manual file upload or fix SharePoint permissions.', 'sharepoint_error': True }), 500 except Exception as e: error_msg = str(e) logger.error(f"Failed to download from SharePoint: {error_msg}", exc_info=True) # Check if this is a locked file error is_locked_file_error = 'locked' in error_msg.lower() or 'cannot access the file' in error_msg.lower() or 'being used by another process' in error_msg.lower() if is_locked_file_error: # Extract filename from error if possible locked_file_match = None import re # Try to find filename in error message match = re.search(r"['\"]([^'\"]*\.xlsx?)['\"]", error_msg) if match: locked_file_match = match.group(1) locked_file_info = f" ({locked_file_match})" if locked_file_match else "" return jsonify({ 'error': f'Cannot download from SharePoint: File is locked{locked_file_info}', 'details': f'A file in the reports directory is being used by another program (likely Excel). Please close Excel and any other programs that might have this file open, then try again. Error: {error_msg}', 'instructions': [ '1. Close Microsoft Excel completely', '2. Close any file explorer windows showing the reports folder', '3. Wait a few seconds', '4. Try generating the report again', '', 'Alternatively, use manual file upload instead of SharePoint download.' ], 'sharepoint_error': True, 'locked_file_error': True }), 500 # Check if we have existing files as fallback (only for non-locked errors) reports_dir_path = Path(report_config.get('reports_dir', 'reports')) if not reports_dir_path.is_absolute(): script_dir = Path(__file__).parent.absolute() reports_dir_path = script_dir / reports_dir_path if reports_dir_path.exists(): existing_files = list(reports_dir_path.glob('*.xlsx')) + list(reports_dir_path.glob('*.xls')) if existing_files: logger.warning(f"SharePoint download failed, but found {len(existing_files)} existing file(s). Will use these.") downloaded_files = [] # Continue with existing files else: return jsonify({ 'error': f'SharePoint download failed: {error_msg}', 'details': 'No existing files found. Please use manual file upload or fix SharePoint permissions.', 'sharepoint_error': True }), 500 else: return jsonify({ 'error': f'SharePoint download failed: {error_msg}', 'details': 'Reports directory does not exist. Please use manual file upload or fix SharePoint permissions.', 'sharepoint_error': True }), 500 # Generate report with timestamp reports_dir = request_data.get('reports_dir', report_config.get('reports_dir', 'reports')) output_dir_str = report_config.get('output_dir', 'output') output_dir = Path(output_dir_str) if not output_dir.is_absolute(): script_dir = Path(__file__).parent.absolute() output_dir = script_dir / output_dir # Create timestamped filename timestamp = datetime.now().strftime('%Y-%m-%d-%H-%M-%S') report_id = f"report-{timestamp}" output_file = str(output_dir / f"{report_id}.json") # Log which files will be used for generation reports_dir_path = Path(reports_dir) if not reports_dir_path.is_absolute(): script_dir = Path(__file__).parent.absolute() reports_dir_path = script_dir / reports_dir_path logger.info(f"Generating report from {reports_dir_path.absolute()}...") logger.info(f"Reports directory exists: {reports_dir_path.exists()}") # Determine which files to use for generation # CRITICAL: Only use files that were just downloaded/uploaded, not old ones if downloaded_files: # Files were downloaded from SharePoint - use only those logger.info(f"Using {len(downloaded_files)} file(s) downloaded from SharePoint") # Verify that reports_dir only contains the downloaded files (should be empty of old files) all_files = list(reports_dir_path.glob('*.xlsx')) + list(reports_dir_path.glob('*.xls')) downloaded_file_paths = [Path(f).name for f in downloaded_files] # Get just filenames if len(all_files) != len(downloaded_files): logger.warning(f"WARNING: Found {len(all_files)} file(s) in reports_dir but only {len(downloaded_files)} were downloaded!") logger.warning("This might indicate old files weren't cleared. Clearing now...") for file in all_files: if file.name not in downloaded_file_paths: try: file.unlink() logger.info(f"Cleared unexpected file: {file.name}") except Exception as e: logger.error(f"Failed to clear unexpected file {file.name}: {e}") elif not download_from_sp: # Manual upload was used (download_from_sharepoint=False) # Upload endpoint should have cleared old files before saving new ones # Use ALL files in the directory (they should all be from the recent upload) if reports_dir_path.exists(): excel_files = list(reports_dir_path.glob('*.xlsx')) + list(reports_dir_path.glob('*.xls')) current_time = datetime.now().timestamp() recent_files = [] logger.info(f"Manual upload generation: Found {len(excel_files)} file(s) in reports directory") # Only use files modified in the last 2 minutes (very recent = just uploaded) # This ensures we don't accidentally use SharePoint-downloaded files for excel_file in excel_files: mtime = excel_file.stat().st_mtime age_seconds = current_time - mtime # Only use files uploaded in the last 2 minutes (120 seconds) # This is tight enough to catch only the most recent upload if age_seconds < 120: # 2 minutes recent_files.append(excel_file) mtime_str = datetime.fromtimestamp(mtime).strftime('%Y-%m-%d %H:%M:%S') logger.info(f" - {excel_file.name} (modified: {mtime_str}, age: {age_seconds:.1f}s) - will be used for manual upload generation") else: logger.warning(f" - {excel_file.name} (modified: {datetime.fromtimestamp(mtime).strftime('%Y-%m-%d %H:%M:%S')}, age: {age_seconds:.1f}s) - skipping (too old, might be from SharePoint download)") # Clear any files that are too old (likely from SharePoint) if len(recent_files) < len(excel_files): logger.warning(f"Found {len(excel_files)} total file(s), but only {len(recent_files)} are recent (< 2 min old). Clearing old files...") for excel_file in excel_files: if excel_file not in recent_files: try: excel_file.unlink() logger.info(f"Cleared old file (likely from SharePoint): {excel_file.name}") except Exception as e: logger.warning(f"Failed to clear old file {excel_file.name}: {e}") if len(recent_files) == 0: logger.error("Manual upload was used but no recent files (< 2 min old) found in reports directory!") logger.error("This might mean:") logger.error("1. Files were not uploaded successfully") logger.error("2. Upload happened more than 2 minutes ago") logger.error("3. File modification times are incorrect") logger.error("4. SharePoint download happened after upload") return jsonify({ 'error': 'No recent files found for manual upload generation', 'details': 'Files were uploaded but not found or are too old. Please try uploading again and generating immediately.', 'manual_upload_error': True }), 400 logger.info(f"Will generate report from {len(recent_files)} recently uploaded file(s)") else: logger.error("Manual upload was used but reports directory does not exist!") return jsonify({ 'error': 'Reports directory does not exist', 'details': 'Cannot generate report from manual upload - reports directory is missing.', 'manual_upload_error': True }), 500 else: # SharePoint download was attempted but failed - this shouldn't happen if download_from_sp=True # But if it does, we should NOT use existing files as they might be old logger.error("SharePoint download was requested but failed, and no manual upload was used!") logger.error("This should not happen - refusing to use potentially old files") return jsonify({ 'error': 'SharePoint download failed and no manual upload provided', 'details': 'Cannot generate report - no data source available. Please try again or use manual upload.', 'sharepoint_error': True }), 400 # FINAL VERIFICATION: Before generation, ensure only expected files exist final_files = list(reports_dir_path.glob('*.xlsx')) + list(reports_dir_path.glob('*.xls')) if len(final_files) > 1: logger.error(f"CRITICAL: Found {len(final_files)} Excel file(s) before generation!") logger.error("This will cause data mixing. Files found:") for f in final_files: mtime = f.stat().st_mtime mtime_str = datetime.fromtimestamp(mtime).strftime('%Y-%m-%d %H:%M:%S') logger.error(f" - {f.name} (modified: {mtime_str})") logger.error("Attempting to keep only the most recent file...") # Keep only the newest file final_files_sorted = sorted(final_files, key=lambda f: f.stat().st_mtime, reverse=True) newest_file = final_files_sorted[0] for old_file in final_files_sorted[1:]: try: old_file.unlink() logger.info(f"Removed older file before generation: {old_file.name}") except Exception as e: logger.error(f"Failed to remove {old_file.name}: {e}") return jsonify({ 'error': f'Multiple Excel files found and cannot remove old ones', 'details': f'Found {len(final_files)} files. Please ensure only one file exists. Files may be locked.', 'files_found': [f.name for f in final_files] }), 400 logger.warning(f"Proceeding with only the newest file: {newest_file.name}") report_data = generate_report( reports_dir=str(reports_dir_path), output_file=output_file, verbose=False # Don't print to console in API mode ) if report_data: # Generate HTML with same timestamp html_file = output_dir / f"{report_id}.html" from html_generator import generate_html_report generate_html_report(output_file, str(html_file)) # Cleanup old reports (keep only last 10) # Ensure reports_dir is a Path object reports_dir_for_cleanup = Path(reports_dir) if isinstance(reports_dir, str) else reports_dir cleanup_old_reports(output_dir, reports_dir_for_cleanup, max_reports=10) return jsonify({ 'status': 'success', 'message': 'Report generated successfully', 'report_id': report_id, 'report_date': timestamp, 'output_file': output_file, 'summary': report_data.get('summary', {}), 'vendors_count': len(report_data.get('vendors', [])), 'downloaded_files': len(downloaded_files) if download_from_sp else 0, 'downloaded_file_names': [Path(f).name for f in downloaded_files] if download_from_sp else [] }) else: return jsonify({ 'error': 'Report generation failed' }), 500 except Exception as e: logger.error(f"Error generating report: {e}", exc_info=True) return jsonify({ 'error': f'Report generation failed: {str(e)}' }), 500 @app.route('/api/upload', methods=['POST']) def upload_files(): """Upload Excel files manually. Clears old files before uploading new ones.""" logger.info("=== MANUAL UPLOAD REQUEST RECEIVED ===") try: if 'files' not in request.files: logger.error("Upload request missing 'files' field") return jsonify({'error': 'No files provided'}), 400 files = request.files.getlist('files') logger.info(f"Received {len(files)} file(s) in upload request") if not files or all(f.filename == '' for f in files): logger.error("No valid files in upload request") return jsonify({'error': 'No files selected'}), 400 report_config = app.config['REPORT_CONFIG'] reports_dir_str = report_config.get('reports_dir', 'reports') reports_dir = Path(reports_dir_str) if not reports_dir.is_absolute(): script_dir = Path(__file__).parent.absolute() reports_dir = script_dir / reports_dir # Ensure reports directory exists reports_dir.mkdir(parents=True, exist_ok=True) # ALWAYS clear ALL old Excel files from reports directory before uploading new ones # CRITICAL: This prevents combining multiple files in report generation old_excel_files = list(reports_dir.glob('*.xlsx')) + list(reports_dir.glob('*.xls')) cleared_count = 0 failed_to_clear = [] for old_file in old_excel_files: try: # On Windows, files might be locked - try multiple times with increasing delays max_retries = 5 retry_count = 0 cleared_this_file = False while retry_count < max_retries and not cleared_this_file: try: old_file.unlink() cleared_count += 1 cleared_this_file = True logger.info(f"Cleared old file before upload: {old_file.name}") break except PermissionError as pe: retry_count += 1 if retry_count < max_retries: # Increasing delay: 0.5s, 1s, 2s, 3s import time delay = min(0.5 * (2 ** retry_count), 3.0) logger.warning(f"File {old_file.name} is locked (attempt {retry_count}/{max_retries}), waiting {delay}s...") time.sleep(delay) else: # Last attempt failed - try renaming instead of deleting logger.warning(f"Cannot delete {old_file.name}, trying to rename instead...") try: import time timestamp = int(time.time()) backup_name = f"{old_file.stem}_backup_{timestamp}{old_file.suffix}" backup_path = old_file.parent / backup_name old_file.rename(backup_path) cleared_count += 1 cleared_this_file = True logger.info(f"Renamed locked file to backup: {old_file.name} -> {backup_name}") except Exception as rename_error: logger.error(f"Could not rename file either: {rename_error}") raise pe # Raise original PermissionError except Exception as e: if retry_count >= max_retries - 1: raise retry_count += 1 import time time.sleep(1) if not cleared_this_file: failed_to_clear.append(old_file.name) logger.error(f"Failed to clear old file {old_file.name} after {max_retries} attempts") except Exception as e: if old_file.name not in failed_to_clear: failed_to_clear.append(old_file.name) logger.error(f"Failed to clear old file {old_file.name}: {e}") # If any files failed to clear, fail the upload to prevent mixing old and new data if failed_to_clear: logger.error(f"CRITICAL: Failed to clear {len(failed_to_clear)} file(s) before upload: {failed_to_clear}") locked_files_list = ', '.join(failed_to_clear) return jsonify({ 'error': f'Cannot upload: {len(failed_to_clear)} file(s) are locked', 'failed_files': failed_to_clear, 'details': f'File(s) {locked_files_list} are being used by another program (likely Excel). Please close Excel and any other programs that might have these files open, then try again.', 'instructions': [ '1. Close Microsoft Excel completely', '2. Close any file explorer windows showing these files', '3. Wait a few seconds', '4. Try uploading again' ] }), 500 if cleared_count > 0: logger.info(f"Cleared {cleared_count} old Excel file(s) before upload") else: logger.info("No old Excel files found to clear (reports directory was empty)") # VERIFY: Double-check that all Excel files are actually gone remaining_files = list(reports_dir.glob('*.xlsx')) + list(reports_dir.glob('*.xls')) if remaining_files: logger.error(f"CRITICAL: After clearing, {len(remaining_files)} file(s) still exist: {[f.name for f in remaining_files]}") logger.error("These files are likely locked. Attempting force removal...") force_failed = [] for remaining_file in remaining_files: try: remaining_file.unlink() logger.info(f"Force-removed locked file: {remaining_file.name}") except Exception as e: force_failed.append(remaining_file.name) logger.error(f"CRITICAL: Cannot remove locked file {remaining_file.name}: {e}") if force_failed: logger.error(f"CRITICAL: {len(force_failed)} file(s) still locked after force removal: {force_failed}") return jsonify({ 'error': f'Cannot upload: {len(force_failed)} file(s) are locked and cannot be deleted', 'failed_files': force_failed, 'details': 'Please close Excel or any other program using these files, then try again.' }), 500 logger.info("✓ Verified: All old Excel files cleared successfully before upload") uploaded_count = 0 uploaded_files = [] for file in files: if file.filename == '': continue # Check if it's an Excel file filename = secure_filename(file.filename) if not (filename.endswith('.xlsx') or filename.endswith('.xls')): logger.warning(f"Skipping non-Excel file: {filename}") continue # Save file to reports directory file_path = reports_dir / filename logger.info(f"Saving uploaded file: {filename} -> {file_path}") file.save(str(file_path)) # Verify file was saved and get its modification time if file_path.exists(): mtime = file_path.stat().st_mtime mtime_str = datetime.fromtimestamp(mtime).strftime('%Y-%m-%d %H:%M:%S') file_size = file_path.stat().st_size logger.info(f"Successfully saved file: {filename} (size: {file_size} bytes, modified: {mtime_str})") uploaded_count += 1 uploaded_files.append(filename) else: logger.error(f"CRITICAL: File was not saved! {file_path} does not exist after save()") raise Exception(f"Failed to save file {filename}") if uploaded_count == 0: return jsonify({'error': 'No valid Excel files uploaded'}), 400 # Warn if multiple files uploaded - reports should be generated from ONE file if uploaded_count > 1: logger.warning(f"WARNING: {uploaded_count} files uploaded. Reports should be generated from a single file. Only the newest file will be used.") return jsonify({ 'status': 'success', 'message': f'Successfully uploaded {uploaded_count} file(s)', 'uploaded_count': uploaded_count, 'uploaded_files': uploaded_files, 'cleared_old_files': cleared_count, 'warning': f'{uploaded_count} file(s) uploaded - only the newest will be used for report generation' if uploaded_count > 1 else None }) except Exception as e: logger.error(f"Error uploading files: {e}", exc_info=True) return jsonify({'error': f'Failed to upload files: {str(e)}'}), 500 @app.route('/api/status', methods=['GET']) def status(): """Get service status and configuration.""" return jsonify({ 'status': 'running', 'sharepoint_enabled': app.config['SHAREPOINT_CONFIG'].get('enabled', False), 'reports_dir': app.config['REPORT_CONFIG'].get('reports_dir', 'reports'), 'output_dir': app.config['REPORT_CONFIG'].get('output_dir', 'output') }) @app.route('/api/report/json', methods=['GET']) def get_report_json(): """Get latest report JSON file.""" try: report_config = app.config['REPORT_CONFIG'] output_dir_str = report_config.get('output_dir', 'output') output_dir = Path(output_dir_str) if not output_dir.is_absolute(): script_dir = Path(__file__).parent.absolute() output_dir = script_dir / output_dir report_file = output_dir / 'report.json' if not report_file.exists(): return jsonify({'error': 'Report not found. Generate a report first.'}), 404 with open(report_file, 'r', encoding='utf-8') as f: report_data = json.load(f) return jsonify(report_data) except Exception as e: logger.error(f"Error reading report JSON: {e}", exc_info=True) return jsonify({'error': f'Failed to read report: {str(e)}'}), 500 @app.route('/api/report/html', methods=['GET']) def get_report_html(): """Get report HTML file by report_id (or latest if not specified).""" try: from flask import send_from_directory report_config = app.config['REPORT_CONFIG'] output_dir_str = report_config.get('output_dir', 'output') output_dir = Path(output_dir_str) if not output_dir.is_absolute(): script_dir = Path(__file__).parent.absolute() output_dir = script_dir / output_dir # Get report_id from query parameter, default to latest report_id = request.args.get('report_id') if report_id: # Check if it's a timestamped report or legacy report html_file = output_dir / f"{report_id}.html" # If not found and it starts with "report-", might be a legacy report with generated ID if not html_file.exists() and report_id.startswith('report-'): # Try legacy report.html legacy_file = output_dir / 'report.html' if legacy_file.exists(): html_file = legacy_file else: return jsonify({'error': f'Report {report_id} not found.'}), 404 elif not html_file.exists(): return jsonify({'error': f'Report {report_id} not found.'}), 404 else: # Get latest report (check both timestamped and legacy) timestamped_files = list(output_dir.glob('report-*.html')) legacy_file = output_dir / 'report.html' html_files = [] if legacy_file.exists(): html_files.append(legacy_file) html_files.extend(timestamped_files) if not html_files: return jsonify({'error': 'No reports found. Generate a report first.'}), 404 html_file = sorted(html_files, key=lambda p: p.stat().st_mtime, reverse=True)[0] return send_from_directory(str(output_dir), html_file.name, mimetype='text/html') except Exception as e: logger.error(f"Error reading report HTML: {e}", exc_info=True) return jsonify({'error': f'Failed to read report HTML: {str(e)}'}), 500 @app.route('/api/reports/list', methods=['GET']) def list_reports(): """List all available reports (last 10).""" try: report_config = app.config['REPORT_CONFIG'] output_dir_str = report_config.get('output_dir', 'output') output_dir = Path(output_dir_str) # Ensure absolute path if not output_dir.is_absolute(): script_dir = Path(__file__).parent.absolute() output_dir = script_dir / output_dir # Log for debugging logger.info(f"Looking for reports in: {output_dir.absolute()}") logger.info(f"Output directory exists: {output_dir.exists()}") if output_dir.exists(): logger.info(f"Files in output directory: {list(output_dir.glob('*'))}") # Find all report HTML files (both timestamped and non-timestamped) timestamped_files = list(output_dir.glob('report-*.html')) legacy_file = output_dir / 'report.html' logger.info(f"Found {len(timestamped_files)} timestamped report files") logger.info(f"Legacy report.html exists: {legacy_file.exists()}") if legacy_file.exists(): logger.info(f"Legacy report.html path: {legacy_file.absolute()}") html_files = [] # Add legacy report.html if it exists if legacy_file.exists(): html_files.append(legacy_file) logger.info("Added legacy report.html to list") # Add timestamped files html_files.extend(timestamped_files) logger.info(f"Total HTML files found: {len(html_files)}") reports = [] for html_file in sorted(html_files, key=lambda p: p.stat().st_mtime, reverse=True)[:10]: report_id = html_file.stem # e.g., "report-2025-11-08-11-25-46" or "report" # Handle legacy report.html if report_id == 'report': # Use file modification time as timestamp mtime = html_file.stat().st_mtime dt = datetime.fromtimestamp(mtime) timestamp_str = dt.strftime('%Y-%m-%d-%H-%M-%S') date_str = dt.strftime('%Y-%m-%d %H:%M:%S') report_id = f"report-{timestamp_str}" else: # Timestamped report timestamp_str = report_id.replace('report-', '') try: # Parse timestamp to create readable date dt = datetime.strptime(timestamp_str, '%Y-%m-%d-%H-%M-%S') date_str = dt.strftime('%Y-%m-%d %H:%M:%S') except: date_str = timestamp_str # Get file size file_size = html_file.stat().st_size reports.append({ 'report_id': report_id, 'date': date_str, 'timestamp': timestamp_str, 'file_size': file_size }) return jsonify({ 'reports': reports, 'count': len(reports) }) except Exception as e: logger.error(f"Error listing reports: {e}", exc_info=True) return jsonify({'error': f'Failed to list reports: {str(e)}'}), 500 return app def start_scheduler(config_path: Optional[str] = None): """Start the scheduler in a background thread.""" global scheduler_thread scheduler_config = config.get('scheduler', {}) if not scheduler_config.get('enabled'): logger.info("Scheduler is disabled in configuration") return try: from scheduler import ReportScheduler import threading def run_scheduler(): try: scheduler = ReportScheduler(config_path=config_path) scheduler.start() except Exception as e: logger.error(f"Scheduler error: {e}", exc_info=True) scheduler_thread = threading.Thread(target=run_scheduler, daemon=True) scheduler_thread.start() logger.info("Scheduler started in background thread") except ImportError: logger.warning("Scheduler module not available. Install apscheduler to enable scheduling.") except Exception as e: logger.error(f"Failed to start scheduler: {e}", exc_info=True) def run_server(config_path: Optional[str] = None, host: Optional[str] = None, port: Optional[int] = None): """Run the API server.""" global app, config app = create_app(config_path) # Start scheduler if enabled start_scheduler(config_path) api_config = config.get('api', {}) server_host = host or api_config.get('host', '0.0.0.0') server_port = port or api_config.get('port', 8080) logger.info(f"Starting API server on {server_host}:{server_port}") app.run(host=server_host, port=server_port, debug=False) if __name__ == "__main__": import sys logging.basicConfig( level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' ) config_path = sys.argv[1] if len(sys.argv) > 1 else None # Check if API is enabled config = load_config(config_path) if not config.get('api', {}).get('enabled', False): logger.warning("API is disabled in configuration. Set api.enabled=true to enable.") logger.info("Starting API server anyway (for testing)...") run_server(config_path=config_path)