hm_ai_qc_report_tool/modules/video_master/routes.py
nickviljoen a3aee0de2e Video Master: async campaign search + correct UI labels
- /api/search-campaign now kicks off a background thread and returns
  immediately. The browser polls /api/progress/<session_id> and fetches
  the cached result via the new /api/search-campaign-result/<session_id>
  endpoint when complete. Box folder enumeration on a not-found campaign
  was taking >30s, exceeding the GCP load balancer's response timeout
  and surfacing as 'stream timeout' (not valid JSON) to the user.
- Result cached for 10 min via the existing reporting result_cache
  (filesystem-backed → safe across gunicorn workers).
- Form label/placeholder/hint updated: tool accepts a campaign NUMBER,
  not a campaign name. Placeholder shows '1993857' instead of
  '1011A Spring SS2025'.
2026-05-09 19:52:49 +02:00

301 lines
10 KiB
Python

"""
Video Master Adot Routes.
Campaign-based master-to-adaptation matching workflow:
1. Enter campaign name → search Box
2. Preview found masters & adaptations
3. Start matching → progress tracking
4. View results report
"""
import os
import uuid
import threading
import logging
from flask import (
render_template, request, jsonify, current_app,
Response, stream_with_context, send_file
)
from core.auth import current_user_email
from .blueprint import video_master_bp
from core.utils.progress_tracker import UnifiedProgressTracker
from core.models.qc_report import QCReport
from core.models.database import db
logger = logging.getLogger(__name__)
@video_master_bp.route('/')
@video_master_bp.route('/index')
def index():
"""Main Video Master page with recent jobs."""
try:
recent_reports = QCReport.get_recent(limit=20, report_type='video_master')
except Exception:
recent_reports = []
return render_template(
'video_master/index.html',
active_tab='video-master',
recent_reports=recent_reports
)
@video_master_bp.route('/match')
def match_page():
"""Campaign matching page."""
return render_template(
'video_master/match.html',
active_tab='video-master',
campaigns_folder_id=current_app.config.get('BOX_CAMPAIGNS_FOLDER_ID')
)
@video_master_bp.route('/api/search-campaign', methods=['POST'])
def search_campaign():
"""Kick off an async Box campaign search.
Box folder enumeration on a not-found campaign can take >30s, which
exceeds the upstream load balancer's response timeout and produces a
'stream timeout' 504 to the client. We run the search in a background
thread and return immediately with a session_id; the browser polls
/api/progress/<session_id> until the search is done, then fetches the
cached result via /api/search-campaign-result/<session_id>.
"""
try:
data = request.get_json()
campaign_name = data.get('campaign_name', '').strip()
if not campaign_name:
return jsonify({'error': 'Campaign number is required'}), 400
session_id = str(uuid.uuid4())
campaigns_folder_id = current_app.config['BOX_CAMPAIGNS_FOLDER_ID']
# Initialise the progress tracker immediately so the polling
# endpoint has something to return on the first call.
tracker = UnifiedProgressTracker(session_id)
tracker.update(5, f'Searching Box folder #{campaigns_folder_id} for "{campaign_name}"...')
app = current_app._get_current_object()
def run_search():
from modules.reporting.result_cache import cache_set
with app.app_context():
bg_tracker = UnifiedProgressTracker(session_id)
try:
box_client = app.get_box_client()
from .campaign_matcher import CampaignMatcher
matcher = CampaignMatcher(
session_id=session_id,
box_client=box_client,
campaign_name=campaign_name
)
result = matcher.search_campaign(campaigns_folder_id)
result['campaigns_folder_id'] = campaigns_folder_id
result['searched_for'] = campaign_name
if result.get('error'):
# Cache the not-found shape so the UI can render the
# diagnostic links in a uniform way.
cache_set(f"vm_search_{session_id}", result, ttl=600)
bg_tracker.fail(result['error'])
return
result['session_id'] = session_id
cache_set(f"vm_search_{session_id}", result, ttl=600)
bg_tracker.complete(f'Found campaign: {result.get("campaign_name", campaign_name)}')
except Exception as e:
logger.error(f"Background campaign search error: {e}", exc_info=True)
cache_set(f"vm_search_{session_id}", {
'error': str(e),
'campaigns_folder_id': campaigns_folder_id,
'searched_for': campaign_name,
}, ttl=600)
bg_tracker.fail(f'Search failed: {e}')
thread = threading.Thread(target=run_search, daemon=True)
thread.start()
return jsonify({
'session_id': session_id,
'progress_url': f'/video-master/api/progress/{session_id}',
'result_url': f'/video-master/api/search-campaign-result/{session_id}',
'campaigns_folder_id': campaigns_folder_id,
'searched_for': campaign_name,
})
except Exception as e:
logger.error(f"Campaign search start error: {e}", exc_info=True)
return jsonify({'error': str(e)}), 500
@video_master_bp.route('/api/search-campaign-result/<session_id>', methods=['GET'])
def search_campaign_result(session_id):
"""Return the cached result of an async Box campaign search."""
from modules.reporting.result_cache import cache_get
cached = cache_get(f"vm_search_{session_id}")
if cached is None:
return jsonify({'error': 'Result not yet available or expired'}), 404
if cached.get('error'):
return jsonify(cached), 404
return jsonify(cached)
@video_master_bp.route('/api/start-match', methods=['POST'])
def start_match():
"""Start the matching process in background."""
try:
data = request.get_json()
campaign_name = data.get('campaign_name', '').strip()
campaign_info = data.get('campaign_info', {})
session_id = data.get('session_id', str(uuid.uuid4()))
if not campaign_name or not campaign_info:
return jsonify({'error': 'Missing campaign data'}), 400
user_name = current_user_email()
app = current_app._get_current_object()
def run():
with app.app_context():
from .campaign_matcher import CampaignMatcher
box_client = app.get_box_client()
matcher = CampaignMatcher(
session_id=session_id,
box_client=box_client,
campaign_name=campaign_name,
user=user_name
)
matcher.run(campaign_info)
thread = threading.Thread(target=run)
thread.daemon = True
thread.start()
return jsonify({'success': True, 'session_id': session_id})
except Exception as e:
logger.error(f"Start match error: {e}")
return jsonify({'error': str(e)}), 500
@video_master_bp.route('/progress/<session_id>')
def progress_stream(session_id):
"""SSE progress stream."""
try:
tracker = UnifiedProgressTracker(session_id)
return Response(
stream_with_context(tracker.stream_progress()),
mimetype='text/event-stream',
headers={'Cache-Control': 'no-cache', 'X-Accel-Buffering': 'no'}
)
except Exception as e:
return jsonify({'error': str(e)}), 500
@video_master_bp.route('/api/progress/<session_id>')
def progress_poll(session_id):
"""Polling progress endpoint."""
try:
tracker = UnifiedProgressTracker(session_id)
return jsonify(tracker.get_progress())
except Exception as e:
return jsonify({'error': str(e)}), 500
@video_master_bp.route('/results/<session_id>')
def results(session_id):
"""Show matching results."""
try:
tracker = UnifiedProgressTracker(session_id)
progress = tracker.get_progress()
report = QCReport.query.filter(
QCReport.metadata_json.like(f'%{session_id}%')
).order_by(QCReport.created_at.desc()).first()
html_content = None
if report and report.file_path and os.path.exists(report.file_path):
with open(report.file_path, 'r', encoding='utf-8') as f:
html_content = f.read()
return render_template(
'video_master/results.html',
active_tab='video-master',
session_id=session_id,
progress=progress,
report=report,
html_content=html_content
)
except Exception as e:
return render_template(
'video_master/results.html',
active_tab='video-master',
session_id=session_id,
error=str(e)
)
@video_master_bp.route('/report/<int:report_id>')
def view_report(report_id):
"""View a saved matching report."""
try:
report = QCReport.query.get(report_id)
if not report:
return render_template('video_master/results.html', error='Report not found'), 404
html_content = None
if report.file_path and os.path.exists(report.file_path):
with open(report.file_path, 'r', encoding='utf-8') as f:
html_content = f.read()
return render_template(
'video_master/view_report.html',
active_tab='video-master',
report=report,
html_content=html_content
)
except Exception as e:
return render_template('video_master/results.html', error=str(e)), 500
@video_master_bp.route('/report/<int:report_id>/download')
def download_report(report_id):
"""Serve the saved HTML report as a download."""
try:
report = QCReport.query.get(report_id)
if not report or not report.file_path or not os.path.exists(report.file_path):
return jsonify({'error': 'Report file not found'}), 404
download_name = os.path.basename(report.file_path)
return send_file(
report.file_path,
mimetype='text/html',
as_attachment=True,
download_name=download_name,
)
except Exception as e:
logger.error(f"Download report error: {e}")
return jsonify({'error': str(e)}), 500
@video_master_bp.route('/report/<int:report_id>', methods=['DELETE'])
def delete_report(report_id):
"""Delete a matching report."""
try:
report = QCReport.query.get(report_id)
if not report:
return jsonify({'error': 'Report not found'}), 404
if report.file_path and os.path.exists(report.file_path):
os.remove(report.file_path)
db.session.delete(report)
db.session.commit()
return jsonify({'success': True})
except Exception as e:
db.session.rollback()
return jsonify({'error': str(e)}), 500