- /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'.
301 lines
10 KiB
Python
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
|