vendor_report/web_ui.py
2025-11-08 15:44:43 +04:00

766 lines
26 KiB
Python

#!/usr/bin/env python3
"""
Web UI for Vendor Report Generator
Provides a simple web interface for generating reports, viewing status, and managing configuration.
"""
import logging
import json
from pathlib import Path
from typing import Optional
from datetime import datetime
try:
from flask import Flask, render_template_string, jsonify, request, send_from_directory, redirect, url_for
from flask_cors import CORS
FLASK_AVAILABLE = True
except ImportError:
FLASK_AVAILABLE = False
logging.warning("Flask not installed. Web UI 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
# HTML Template for the Web UI
UI_TEMPLATE = """
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Vendor Report Generator</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
padding: 20px;
}
.container {
max-width: 1200px;
margin: 0 auto;
background: white;
border-radius: 12px;
box-shadow: 0 10px 40px rgba(0,0,0,0.2);
overflow: hidden;
}
header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 30px;
text-align: center;
}
header h1 {
font-size: 2.5em;
margin-bottom: 10px;
}
header p {
opacity: 0.9;
font-size: 1.1em;
}
.content {
padding: 40px;
}
.section {
margin-bottom: 40px;
padding: 30px;
background: #f9fafb;
border-radius: 8px;
border: 1px solid #e5e7eb;
}
.section h2 {
color: #1e40af;
margin-bottom: 20px;
font-size: 1.5em;
}
.button-group {
display: flex;
gap: 15px;
flex-wrap: wrap;
margin-top: 20px;
}
.btn {
padding: 12px 24px;
border: none;
border-radius: 6px;
font-size: 1em;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
text-decoration: none;
display: inline-block;
}
.btn-primary {
background: #2563eb;
color: white;
}
.btn-primary:hover {
background: #1d4ed8;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(37, 99, 235, 0.4);
}
.btn-success {
background: #10b981;
color: white;
}
.btn-success:hover {
background: #059669;
}
.btn-secondary {
background: #6b7280;
color: white;
}
.btn-secondary:hover {
background: #4b5563;
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none;
}
.status-card {
background: white;
padding: 20px;
border-radius: 8px;
border-left: 4px solid #2563eb;
margin-bottom: 15px;
}
.status-card h3 {
color: #374151;
margin-bottom: 10px;
}
.status-card p {
color: #6b7280;
margin: 5px 0;
}
.status-indicator {
display: inline-block;
width: 12px;
height: 12px;
border-radius: 50%;
margin-right: 8px;
}
.status-indicator.active {
background: #10b981;
}
.status-indicator.inactive {
background: #ef4444;
}
.loading {
display: none;
text-align: center;
padding: 20px;
}
.loading.active {
display: block;
}
.spinner {
border: 4px solid #f3f4f6;
border-top: 4px solid #2563eb;
border-radius: 50%;
width: 40px;
height: 40px;
animation: spin 1s linear infinite;
margin: 0 auto 15px;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.alert {
padding: 15px;
border-radius: 6px;
margin-bottom: 20px;
display: none;
}
.alert.active {
display: block;
}
.alert-success {
background: #d1fae5;
color: #065f46;
border: 1px solid #10b981;
}
.alert-error {
background: #fee2e2;
color: #991b1b;
border: 1px solid #ef4444;
}
.alert-info {
background: #dbeafe;
color: #1e40af;
border: 1px solid #2563eb;
}
.report-list {
list-style: none;
}
.report-item {
background: white;
padding: 15px;
border-radius: 6px;
margin-bottom: 10px;
border: 1px solid #e5e7eb;
display: flex;
justify-content: space-between;
align-items: center;
}
.report-item:hover {
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
.report-info {
flex: 1;
}
.report-info strong {
color: #1e40af;
display: block;
margin-bottom: 5px;
}
.report-info small {
color: #6b7280;
}
.config-item {
margin-bottom: 15px;
padding: 15px;
background: white;
border-radius: 6px;
border: 1px solid #e5e7eb;
}
.config-item label {
display: block;
font-weight: 600;
color: #374151;
margin-bottom: 5px;
}
.config-item .value {
color: #6b7280;
font-family: monospace;
}
.badge {
display: inline-block;
padding: 4px 12px;
border-radius: 12px;
font-size: 0.85em;
font-weight: 600;
}
.badge-enabled {
background: #d1fae5;
color: #065f46;
}
.badge-disabled {
background: #fee2e2;
color: #991b1b;
}
</style>
</head>
<body>
<div class="container">
<header>
<h1>📊 Vendor Report Generator</h1>
<p>Generate comprehensive vendor punchlist reports from Excel files</p>
</header>
<div class="content">
<div id="alert-container"></div>
<!-- Update Data Section -->
<div class="section">
<h2>Update Data</h2>
<p>Download the latest Excel files from SharePoint to update your local data.</p>
<div class="button-group">
<button class="btn btn-success" onclick="updateFromSharePoint()">
Update Data from SharePoint
</button>
</div>
<div class="loading" id="loading-update">
<div class="spinner"></div>
<p>Downloading files from SharePoint... This may take a moment.</p>
</div>
</div>
<!-- Generate Report Section -->
<div class="section">
<h2>Generate Report</h2>
<p>Generate a new report from Excel files in the local reports directory.</p>
<div class="button-group">
<button class="btn btn-primary" onclick="generateReport()">
Generate Report
</button>
</div>
<div class="loading" id="loading">
<div class="spinner"></div>
<p>Generating report... This may take a moment.</p>
</div>
</div>
<!-- Status Section -->
<div class="section">
<h2>Service Status</h2>
<div id="status-container">
<div class="status-card">
<h3>Loading status...</h3>
</div>
</div>
</div>
<!-- Reports Section -->
<div class="section">
<h2>Generated Reports</h2>
<div id="reports-container">
<p>Loading reports...</p>
</div>
</div>
<!-- Configuration Section -->
<div class="section">
<h2>Configuration</h2>
<div id="config-container">
<p>Loading configuration...</p>
</div>
</div>
</div>
</div>
<script>
// Update data from SharePoint
async function updateFromSharePoint() {
const loading = document.getElementById('loading-update');
const alertContainer = document.getElementById('alert-container');
loading.classList.add('active');
alertContainer.innerHTML = '';
try {
const response = await fetch('/api/update-sharepoint', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
}
});
const data = await response.json();
if (response.ok) {
showAlert('success', `Successfully downloaded ${data.downloaded_count} file(s) from SharePoint!`);
loadStatus();
} else {
showAlert('error', `Error: ${data.error || 'Failed to download from SharePoint'}`);
}
} catch (error) {
showAlert('error', `Error: ${error.message}`);
} finally {
loading.classList.remove('active');
}
}
// Generate report
async function generateReport() {
const loading = document.getElementById('loading');
const alertContainer = document.getElementById('alert-container');
loading.classList.add('active');
alertContainer.innerHTML = '';
try {
const response = await fetch('/api/generate', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
download_from_sharepoint: false
})
});
const data = await response.json();
if (response.ok) {
showAlert('success', `Report generated successfully! Processed ${data.vendors_count || 0} vendors.`);
loadReports();
loadStatus();
} else {
showAlert('error', `Error: ${data.error || 'Failed to generate report'}`);
}
} catch (error) {
showAlert('error', `Error: ${error.message}`);
console.error('Generate report error:', error);
} finally {
loading.classList.remove('active');
}
}
// Load status
async function loadStatus() {
try {
const response = await fetch('/api/status');
const data = await response.json();
const container = document.getElementById('status-container');
container.innerHTML = `
<div class="status-card">
<h3>
<span class="status-indicator ${data.status === 'running' ? 'active' : 'inactive'}"></span>
Service Status: ${data.status}
</h3>
<p><strong>SharePoint:</strong> <span class="badge ${data.sharepoint_enabled ? 'badge-enabled' : 'badge-disabled'}">${data.sharepoint_enabled ? 'Enabled' : 'Disabled'}</span></p>
<p><strong>Reports Directory:</strong> ${data.reports_dir}</p>
<p><strong>Output Directory:</strong> ${data.output_dir}</p>
</div>
`;
} catch (error) {
console.error('Failed to load status:', error);
}
}
// Load reports
async function loadReports() {
try {
const response = await fetch('/api/reports');
const data = await response.json();
const container = document.getElementById('reports-container');
if (data.reports && data.reports.length > 0) {
const reportsList = data.reports.map(report => `
<div class="report-item">
<div class="report-info">
<strong>${report.name}</strong>
<small>Generated: ${report.generated_at} | Size: ${report.size}</small>
</div>
<div>
<a href="/reports/${report.name}" class="btn btn-primary" target="_blank">View HTML</a>
${report.json_exists ? `<a href="/reports/${report.json_name}" class="btn btn-secondary" download>Download JSON</a>` : ''}
</div>
</div>
`).join('');
container.innerHTML = `<ul class="report-list">${reportsList}</ul>`;
} else {
container.innerHTML = '<p>No reports generated yet.</p>';
} catch (error) {
console.error('Failed to load reports:', error);
document.getElementById('reports-container').innerHTML = '<p>Error loading reports.</p>';
}
}
// Load configuration
async function loadConfig() {
try {
const response = await fetch('/api/config');
const config = await response.json();
const container = document.getElementById('config-container');
const configItems = Object.entries(config).map(([key, value]) => {
const displayValue = typeof value === 'boolean'
? `<span class="badge ${value ? 'badge-enabled' : 'badge-disabled'}">${value ? 'Enabled' : 'Disabled'}</span>`
: String(value || 'Not configured');
return `
<div class="config-item">
<label>${key.replace(/_/g, ' ').replace(/\\b\\w/g, l => l.toUpperCase())}</label>
<div class="value">${displayValue}</div>
</div>
`;
}).join('');
container.innerHTML = configItems;
} catch (error) {
console.error('Failed to load config:', error);
document.getElementById('config-container').innerHTML = '<p>Error loading configuration.</p>';
}
}
// Show alert
function showAlert(type, message) {
const container = document.getElementById('alert-container');
const alert = document.createElement('div');
alert.className = `alert alert-${type} active`;
alert.textContent = message;
container.appendChild(alert);
setTimeout(() => {
alert.remove();
}, 5000);
}
// Load data on page load
window.addEventListener('DOMContentLoaded', () => {
loadStatus();
loadReports();
loadConfig();
// Refresh every 30 seconds
setInterval(() => {
loadStatus();
loadReports();
}, 30000);
});
</script>
</body>
</html>
"""
def create_app(config_path: Optional[str] = None):
"""Create and configure Flask app with Web UI."""
global app, config
if not FLASK_AVAILABLE:
raise ImportError(
"Flask is required for Web UI. "
"Install it with: pip install flask flask-cors"
)
app = Flask(__name__)
CORS(app)
config = load_config(config_path)
api_config = config.get('api', {})
sharepoint_config = config.get('sharepoint', {})
report_config = config.get('report', {})
app.config['API_KEY'] = api_config.get('api_key')
app.config['SHAREPOINT_CONFIG'] = sharepoint_config
app.config['REPORT_CONFIG'] = report_config
@app.route('/')
def index():
"""Main web UI page."""
return render_template_string(UI_TEMPLATE)
@app.route('/api/update-sharepoint', methods=['POST'])
def update_sharepoint_endpoint():
"""Download files from SharePoint."""
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:
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)
)
logger.info(f"Downloaded {len(downloaded)} file(s) from SharePoint")
return jsonify({
'status': 'success',
'message': f'Successfully downloaded {len(downloaded)} file(s) from SharePoint',
'downloaded_count': len(downloaded),
'files': downloaded
})
except Exception as e:
logger.error(f"Failed to download from SharePoint: {e}", exc_info=True)
return jsonify({'error': f'SharePoint download failed: {str(e)}'}), 500
except Exception as e:
logger.error(f"Error updating from SharePoint: {e}", exc_info=True)
return jsonify({'error': f'Update failed: {str(e)}'}), 500
@app.route('/api/generate', methods=['POST'])
def generate_report_endpoint():
"""Generate report on demand."""
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 {}
report_config = app.config['REPORT_CONFIG']
reports_dir = request_data.get('reports_dir', report_config.get('reports_dir', 'reports'))
output_file = request_data.get('output_file',
str(Path(report_config.get('output_dir', 'output')) / 'report.json'))
# Check if reports directory exists and has files
reports_path = Path(reports_dir)
if not reports_path.exists():
return jsonify({'error': f'Reports directory not found: {reports_dir}'}), 400
excel_files = list(reports_path.glob('*.xlsx')) + list(reports_path.glob('*.xls'))
if not excel_files:
return jsonify({'error': f'No Excel files found in {reports_dir}. Please update data from SharePoint first.'}), 400
logger.info(f"Generating report from {reports_dir} ({len(excel_files)} Excel file(s))...")
report_data = generate_report(
reports_dir=reports_dir,
output_file=output_file,
verbose=False
)
if report_data and report_data.get('vendors'):
return jsonify({
'status': 'success',
'message': 'Report generated successfully',
'output_file': output_file,
'summary': report_data.get('summary', {}),
'vendors_count': len(report_data.get('vendors', []))
})
else:
return jsonify({'error': 'Report generation failed - no data processed'}), 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/status', methods=['GET'])
def status():
"""Get service status."""
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/reports', methods=['GET'])
def list_reports():
"""List generated reports."""
output_dir = Path(app.config['REPORT_CONFIG'].get('output_dir', 'output'))
reports = []
if output_dir.exists():
html_files = list(output_dir.glob('*.html'))
for html_file in html_files:
json_file = html_file.with_suffix('.json')
reports.append({
'name': html_file.name,
'json_name': json_file.name if json_file.exists() else None,
'json_exists': json_file.exists(),
'generated_at': datetime.fromtimestamp(html_file.stat().st_mtime).strftime('%Y-%m-%d %H:%M:%S'),
'size': f"{html_file.stat().st_size / 1024:.1f} KB"
})
# Sort by modification time (newest first)
reports.sort(key=lambda x: x['generated_at'], reverse=True)
return jsonify({'reports': reports})
@app.route('/api/config', methods=['GET'])
def get_config():
"""Get configuration (safe, no secrets)."""
return jsonify({
'sharepoint_enabled': app.config['SHAREPOINT_CONFIG'].get('enabled', False),
'sharepoint_site_url': app.config['SHAREPOINT_CONFIG'].get('site_url', 'Not configured'),
'sharepoint_folder_path': app.config['SHAREPOINT_CONFIG'].get('folder_path', 'Not configured'),
'reports_dir': app.config['REPORT_CONFIG'].get('reports_dir', 'reports'),
'output_dir': app.config['REPORT_CONFIG'].get('output_dir', 'output')
})
@app.route('/reports/<filename>')
def serve_report(filename):
"""Serve report files."""
output_dir = Path(app.config['REPORT_CONFIG'].get('output_dir', 'output'))
return send_from_directory(str(output_dir), filename)
@app.route('/health', methods=['GET'])
def health():
"""Health check."""
return jsonify({'status': 'healthy', 'service': 'vendor-report-generator-ui'})
return app
def run_server(config_path: Optional[str] = None, host: Optional[str] = None, port: Optional[int] = None):
"""Run the Web UI server."""
app = create_app(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 Web UI server on http://{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
config = load_config(config_path)
if not config.get('api', {}).get('enabled', False):
logger.warning("API is disabled in configuration, but starting Web UI anyway...")
run_server(config_path=config_path)