hm_ai_qc_report_tool/app.py
nickviljoen 1dff8fece5 Fix auth flow: switch from popup to redirect-based MSAL login
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>
2026-02-25 12:22:33 +02:00

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
)