The popup login flow was broken because the Flask 302 redirect from / to /reporting/index caused MSAL in the popup to consume the auth code hash before the parent window could detect it, leaving the parent stuck on "Authenticating..." while the popup rendered the full app. - Switch signIn() from loginPopup() to loginRedirect() - Add handleRedirectPromise() at start of initAuth() to process the auth code on page load after returning from Microsoft - Change root route from 302 redirect to direct template render so the #code=... hash fragment is preserved for MSAL - Switch signOut() from logoutPopup() to clearCache() Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
456 lines
15 KiB
Python
456 lines
15 KiB
Python
"""
|
|
Flask Application Factory for Unified HM QC Platform.
|
|
|
|
This application merges multiple QC tools into a single platform with:
|
|
- HM QC (PDF/image quality control)
|
|
- Video QC (video quality control)
|
|
- Video Master Adot Detection (video matching)
|
|
- Reporting (consolidated reports from Box.com and QC modules)
|
|
"""
|
|
import logging
|
|
import os
|
|
from flask import Flask, render_template, request, jsonify, send_file
|
|
from datetime import datetime
|
|
from io import BytesIO
|
|
|
|
# Import configuration
|
|
import config as app_config
|
|
|
|
# Import core modules
|
|
from core.auth.middleware import AuthMiddleware
|
|
from core.models.database import init_db, db
|
|
from core.services.box_client import BoxReportClient
|
|
from core.utils.report_parser import QCReportParser, aggregate_reports
|
|
|
|
# Configure logging
|
|
logging.basicConfig(
|
|
level=logging.INFO,
|
|
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
|
)
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def create_app(config_class=app_config.Config):
|
|
"""
|
|
Application factory function.
|
|
|
|
Args:
|
|
config_class: Configuration class to use
|
|
|
|
Returns:
|
|
Configured Flask application instance
|
|
"""
|
|
# Initialize Flask app
|
|
app = Flask(__name__)
|
|
app.config.from_object(config_class)
|
|
|
|
# Create necessary directories
|
|
os.makedirs('database', exist_ok=True)
|
|
os.makedirs('uploads/hm_qc', exist_ok=True)
|
|
os.makedirs('uploads/video_qc', exist_ok=True)
|
|
os.makedirs('uploads/video_master', exist_ok=True)
|
|
os.makedirs('storage/reports/hm_qc', exist_ok=True)
|
|
os.makedirs('storage/reports/consolidated', exist_ok=True)
|
|
|
|
# Initialize database
|
|
init_db(app)
|
|
logger.info("Database initialized")
|
|
|
|
# Initialize authentication middleware
|
|
auth = AuthMiddleware(app)
|
|
logger.info("Authentication initialized")
|
|
|
|
# Store auth in app context for use in routes
|
|
app.auth = auth
|
|
|
|
# Initialize Box client (lazy loading)
|
|
app._box_client = None
|
|
|
|
def get_box_client():
|
|
"""Get or initialize Box client."""
|
|
if app._box_client is None:
|
|
try:
|
|
app._box_client = BoxReportClient(
|
|
config_path=app.config['BOX_CONFIG_PATH'],
|
|
report_folder_id=app.config['BOX_REPORT_FOLDER_ID']
|
|
)
|
|
logger.info("Box client initialized successfully")
|
|
except Exception as e:
|
|
logger.error(f"Failed to initialize Box client: {e}")
|
|
raise
|
|
return app._box_client
|
|
|
|
# Store box client getter in app context
|
|
app.get_box_client = get_box_client
|
|
|
|
# Register blueprints
|
|
# Auth blueprint (for login/logout endpoints)
|
|
from core.auth.routes import auth_bp
|
|
app.register_blueprint(auth_bp)
|
|
logger.info("Auth blueprint registered at /auth")
|
|
|
|
# Task #3: Reporting blueprint (COMPLETED)
|
|
from modules.reporting import reporting_bp
|
|
app.register_blueprint(reporting_bp)
|
|
logger.info("Reporting blueprint registered at /reporting")
|
|
|
|
# Task #4: HM QC blueprint (COMPLETED)
|
|
from modules.hm_qc import hm_qc_bp
|
|
app.register_blueprint(hm_qc_bp)
|
|
logger.info("HM QC blueprint registered at /hm-qc")
|
|
|
|
# Task #5: Video QC blueprint (BETA)
|
|
from modules.video_qc import video_qc_bp
|
|
app.register_blueprint(video_qc_bp)
|
|
logger.info("Video QC blueprint (BETA) registered at /video-qc")
|
|
|
|
# Task #6: Video Master blueprint (BETA)
|
|
from modules.video_master import video_master_bp
|
|
app.register_blueprint(video_master_bp)
|
|
logger.info("Video Master blueprint (BETA) registered at /video-master")
|
|
|
|
# Register root route - render reporting page directly (no 302 redirect).
|
|
# A redirect would strip the URL hash fragment (#code=...) that MSAL
|
|
# needs to process after returning from Microsoft login.
|
|
@app.route('/')
|
|
def root():
|
|
"""Render reporting index directly at root."""
|
|
return render_template('reporting/index.html', active_tab='reporting')
|
|
|
|
# Register error handlers
|
|
register_error_handlers(app)
|
|
|
|
logger.info("Application initialized successfully")
|
|
return app
|
|
|
|
|
|
def register_legacy_routes(app, auth, get_box_client):
|
|
"""
|
|
Register legacy routes (temporary - will move to Reporting blueprint in Task #3).
|
|
|
|
Args:
|
|
app: Flask application
|
|
auth: AuthMiddleware instance
|
|
get_box_client: Function to get Box client
|
|
"""
|
|
|
|
@app.route('/')
|
|
def index():
|
|
"""Render home page with job number search. Auth handled by frontend JavaScript."""
|
|
return render_template('index.html')
|
|
|
|
@app.route('/search', methods=['POST'])
|
|
@auth.require_auth
|
|
def search():
|
|
"""Search for reports by job number."""
|
|
try:
|
|
data = request.get_json()
|
|
job_number = data.get('job_number', '').strip()
|
|
|
|
if not job_number:
|
|
return jsonify({'error': 'Job number is required'}), 400
|
|
|
|
logger.info(f"Searching for job number: {job_number}")
|
|
|
|
# Search Box for reports
|
|
box_client = get_box_client()
|
|
reports = box_client.search_by_job_number(job_number)
|
|
|
|
if not reports:
|
|
return jsonify({
|
|
'job_number': job_number,
|
|
'reports': [],
|
|
'message': f'No reports found for job number: {job_number}'
|
|
})
|
|
|
|
return jsonify({
|
|
'job_number': job_number,
|
|
'reports': reports,
|
|
'count': len(reports)
|
|
})
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error searching for reports: {e}")
|
|
return jsonify({'error': str(e)}), 500
|
|
|
|
@app.route('/dashboard/<job_number>')
|
|
@auth.require_auth
|
|
def dashboard(job_number):
|
|
"""Render dashboard for a specific job number."""
|
|
try:
|
|
logger.info(f"Loading dashboard for job number: {job_number}")
|
|
|
|
# Search for reports
|
|
box_client = get_box_client()
|
|
reports = box_client.search_by_job_number(job_number)
|
|
|
|
if not reports:
|
|
return render_template(
|
|
'dashboard.html',
|
|
job_number=job_number,
|
|
error=f'No reports found for job number: {job_number}'
|
|
)
|
|
|
|
# Download and parse reports
|
|
parsed_reports = []
|
|
|
|
for report_info in reports:
|
|
try:
|
|
content = box_client.download_file(report_info['id'])
|
|
html_content = content.decode('utf-8')
|
|
|
|
parser = QCReportParser(html_content)
|
|
parsed_data = parser.parse()
|
|
|
|
parsed_data['box_id'] = report_info['id']
|
|
parsed_data['box_name'] = report_info['name']
|
|
parsed_data['modified_at'] = report_info['modified_at']
|
|
parsed_data['html_content'] = html_content
|
|
|
|
parsed_reports.append(parsed_data)
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error parsing report {report_info['name']}: {e}")
|
|
parsed_reports.append({
|
|
'filename': report_info['name'],
|
|
'error': f'Failed to parse report: {str(e)}',
|
|
'box_id': report_info['id']
|
|
})
|
|
|
|
# Generate aggregate summary
|
|
aggregated = aggregate_reports(parsed_reports)
|
|
|
|
return render_template(
|
|
'dashboard.html',
|
|
job_number=job_number,
|
|
reports=parsed_reports,
|
|
aggregated=aggregated,
|
|
report_count=len(parsed_reports)
|
|
)
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error loading dashboard: {e}")
|
|
return render_template(
|
|
'dashboard.html',
|
|
job_number=job_number,
|
|
error=str(e)
|
|
)
|
|
|
|
@app.route('/api/report/<file_id>')
|
|
@auth.require_auth
|
|
def get_report(file_id):
|
|
"""Get parsed report data as JSON."""
|
|
try:
|
|
box_client = get_box_client()
|
|
content = box_client.download_file(file_id)
|
|
html_content = content.decode('utf-8')
|
|
|
|
parser = QCReportParser(html_content)
|
|
parsed_data = parser.parse()
|
|
|
|
return jsonify(parsed_data)
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error getting report {file_id}: {e}")
|
|
return jsonify({'error': str(e)}), 500
|
|
|
|
@app.route('/api/report/<file_id>/raw')
|
|
@auth.require_auth
|
|
def get_raw_report(file_id):
|
|
"""Get raw HTML report."""
|
|
try:
|
|
box_client = get_box_client()
|
|
content = box_client.download_file(file_id)
|
|
|
|
return content, 200, {'Content-Type': 'text/html; charset=utf-8'}
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error getting raw report {file_id}: {e}")
|
|
return f'<html><body><h1>Error</h1><p>{str(e)}</p></body></html>', 500
|
|
|
|
@app.route('/export/html/<job_number>')
|
|
@auth.require_auth
|
|
def export_html(job_number):
|
|
"""Export combined report as HTML."""
|
|
try:
|
|
logger.info(f"Exporting HTML for job number: {job_number}")
|
|
|
|
box_client = get_box_client()
|
|
reports = box_client.search_by_job_number(job_number)
|
|
|
|
if not reports:
|
|
return jsonify({'error': f'No reports found for job number: {job_number}'}), 404
|
|
|
|
parsed_reports = []
|
|
for report_info in reports:
|
|
try:
|
|
content = box_client.download_file(report_info['id'])
|
|
html_content = content.decode('utf-8')
|
|
|
|
parser = QCReportParser(html_content)
|
|
parsed_data = parser.parse()
|
|
parsed_data['html_content'] = html_content
|
|
|
|
parsed_reports.append(parsed_data)
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error parsing report for HTML export: {e}")
|
|
|
|
aggregated = aggregate_reports(parsed_reports)
|
|
|
|
html_string = render_template(
|
|
'pdf_export.html',
|
|
job_number=job_number,
|
|
reports=parsed_reports,
|
|
aggregated=aggregated,
|
|
generated_at=datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
|
)
|
|
|
|
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
|
filename = f'QC_Report_{job_number}_{timestamp}.html'
|
|
|
|
return send_file(
|
|
BytesIO(html_string.encode('utf-8')),
|
|
mimetype='text/html',
|
|
as_attachment=True,
|
|
download_name=filename
|
|
)
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error exporting HTML: {e}")
|
|
return jsonify({'error': str(e)}), 500
|
|
|
|
@app.route('/export/html/<job_number>/errors')
|
|
@auth.require_auth
|
|
def export_html_errors_only(job_number):
|
|
"""Export error reports only as HTML."""
|
|
try:
|
|
logger.info(f"Exporting error reports only for job number: {job_number}")
|
|
|
|
box_client = get_box_client()
|
|
reports = box_client.search_by_job_number(job_number)
|
|
|
|
if not reports:
|
|
return jsonify({'error': f'No reports found for job number: {job_number}'}), 404
|
|
|
|
parsed_reports = []
|
|
for report_info in reports:
|
|
try:
|
|
content = box_client.download_file(report_info['id'])
|
|
html_content = content.decode('utf-8')
|
|
|
|
parser = QCReportParser(html_content)
|
|
parsed_data = parser.parse()
|
|
parsed_data['html_content'] = html_content
|
|
|
|
if parsed_data.get('summary', {}).get('error', 0) > 0:
|
|
parsed_reports.append(parsed_data)
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error parsing report for error export: {e}")
|
|
|
|
if not parsed_reports:
|
|
return jsonify({'error': f'No error reports found for job number: {job_number}'}), 404
|
|
|
|
aggregated = aggregate_reports(parsed_reports)
|
|
|
|
html_string = render_template(
|
|
'pdf_export.html',
|
|
job_number=job_number,
|
|
reports=parsed_reports,
|
|
aggregated=aggregated,
|
|
generated_at=datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
|
|
errors_only=True
|
|
)
|
|
|
|
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
|
filename = f'QC_Report_{job_number}_ERRORS_ONLY_{timestamp}.html'
|
|
|
|
return send_file(
|
|
BytesIO(html_string.encode('utf-8')),
|
|
mimetype='text/html',
|
|
as_attachment=True,
|
|
download_name=filename
|
|
)
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error exporting error reports: {e}")
|
|
return jsonify({'error': str(e)}), 500
|
|
|
|
@app.route('/health')
|
|
def health():
|
|
"""Health check endpoint."""
|
|
try:
|
|
box_client = get_box_client()
|
|
|
|
return jsonify({
|
|
'status': 'healthy',
|
|
'timestamp': datetime.now().isoformat(),
|
|
'box_connected': True
|
|
})
|
|
|
|
except Exception as e:
|
|
logger.error(f"Health check failed: {e}")
|
|
return jsonify({
|
|
'status': 'unhealthy',
|
|
'timestamp': datetime.now().isoformat(),
|
|
'error': str(e)
|
|
}), 500
|
|
|
|
# Authentication endpoints
|
|
@app.route('/auth/login', methods=['POST'])
|
|
def auth_login():
|
|
"""Process Azure AD authentication token."""
|
|
try:
|
|
data = request.get_json()
|
|
if not data or 'token' not in data:
|
|
return jsonify({'success': False, 'error': 'Token is required'}), 400
|
|
|
|
token = data['token']
|
|
return auth.set_auth_token(token)
|
|
except Exception as e:
|
|
logger.error(f"Authentication failed: {e}")
|
|
return jsonify({'success': False, 'error': str(e)}), 500
|
|
|
|
@app.route('/auth/logout', methods=['POST'])
|
|
def auth_logout():
|
|
"""Clear authentication token."""
|
|
return auth.clear_auth_token()
|
|
|
|
@app.route('/auth/status', methods=['GET'])
|
|
def auth_status():
|
|
"""Check current authentication status."""
|
|
return jsonify(auth.get_auth_status())
|
|
|
|
|
|
def register_error_handlers(app):
|
|
"""
|
|
Register error handlers.
|
|
|
|
Args:
|
|
app: Flask application
|
|
"""
|
|
|
|
@app.errorhandler(404)
|
|
def not_found(error):
|
|
"""Handle 404 errors."""
|
|
return render_template('404.html'), 404
|
|
|
|
@app.errorhandler(500)
|
|
def internal_error(error):
|
|
"""Handle 500 errors."""
|
|
logger.error(f"Internal server error: {error}")
|
|
return render_template('500.html'), 500
|
|
|
|
|
|
# Create application instance
|
|
app = create_app()
|
|
|
|
|
|
if __name__ == '__main__':
|
|
# Run app
|
|
app.run(
|
|
host=app.config['HOST'],
|
|
port=app.config['PORT'],
|
|
debug=True
|
|
)
|