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'.
This commit is contained in:
parent
a500d7b088
commit
a3aee0de2e
2 changed files with 175 additions and 68 deletions
|
|
@ -51,43 +51,96 @@ def match_page():
|
|||
|
||||
@video_master_bp.route('/api/search-campaign', methods=['POST'])
|
||||
def search_campaign():
|
||||
"""Search Box for a campaign folder and return preview info."""
|
||||
"""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 name is required'}), 400
|
||||
return jsonify({'error': 'Campaign number is required'}), 400
|
||||
|
||||
session_id = str(uuid.uuid4())
|
||||
box_client = current_app.get_box_client()
|
||||
|
||||
from .campaign_matcher import CampaignMatcher
|
||||
matcher = CampaignMatcher(
|
||||
session_id=session_id,
|
||||
box_client=box_client,
|
||||
campaign_name=campaign_name
|
||||
)
|
||||
|
||||
campaigns_folder_id = current_app.config['BOX_CAMPAIGNS_FOLDER_ID']
|
||||
result = matcher.search_campaign(campaigns_folder_id)
|
||||
|
||||
# Always include the searched location so the UI can show it on
|
||||
# both success and failure (helps users self-diagnose missing campaigns).
|
||||
result['campaigns_folder_id'] = campaigns_folder_id
|
||||
result['searched_for'] = campaign_name
|
||||
# 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}"...')
|
||||
|
||||
if result.get('error'):
|
||||
return jsonify(result), 404
|
||||
app = current_app._get_current_object()
|
||||
|
||||
result['session_id'] = session_id
|
||||
return jsonify(result)
|
||||
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 error: {e}", exc_info=True)
|
||||
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."""
|
||||
|
|
|
|||
|
|
@ -29,11 +29,11 @@
|
|||
<div class="card-body">
|
||||
<form id="searchForm">
|
||||
<div class="mb-3">
|
||||
<label for="campaignName" class="form-label">Campaign Name</label>
|
||||
<label for="campaignName" class="form-label">Campaign Number</label>
|
||||
<input type="text" class="form-control form-control-lg" id="campaignName"
|
||||
placeholder="e.g. 1011A Spring SS2025" required autofocus>
|
||||
placeholder="e.g. 1993857" required autofocus>
|
||||
<div class="form-text">
|
||||
Enter the campaign folder name as it appears on Box.
|
||||
Enter the campaign number — folders on Box are named by the campaign number.
|
||||
{% if campaigns_folder_id %}
|
||||
Searches inside Box folder
|
||||
<a href="https://app.box.com/folder/{{ campaigns_folder_id }}" target="_blank">
|
||||
|
|
@ -124,6 +124,68 @@
|
|||
let campaignInfo = null;
|
||||
let sessionId = null;
|
||||
|
||||
function showSearchError(message, folderId, searchedFor) {
|
||||
document.getElementById('errorMessage').textContent = message;
|
||||
const detailEl = document.getElementById('errorDetail');
|
||||
if (folderId && searchedFor) {
|
||||
detailEl.innerHTML =
|
||||
'Searched for <code>' + searchedFor + '</code> inside Box folder ' +
|
||||
'<a href="https://app.box.com/folder/' + folderId + '" target="_blank">' +
|
||||
'#' + folderId + '</a>. ' +
|
||||
'Open the folder in Box to confirm the campaign exists and the name matches.';
|
||||
} else {
|
||||
detailEl.textContent = '';
|
||||
}
|
||||
document.getElementById('errorSection').style.display = 'block';
|
||||
}
|
||||
|
||||
function resetSearchButton() {
|
||||
const btn = document.getElementById('searchBtn');
|
||||
btn.disabled = false;
|
||||
document.getElementById('searchSpinner').style.display = 'none';
|
||||
document.getElementById('searchBtnText').textContent = 'Search Campaign';
|
||||
}
|
||||
|
||||
function setSearchButtonText(text) {
|
||||
document.getElementById('searchBtnText').textContent = text;
|
||||
}
|
||||
|
||||
async function pollSearchProgress(sid, folderId, searchedFor) {
|
||||
const startedAt = Date.now();
|
||||
const TIMEOUT_MS = 5 * 60 * 1000; // give up after 5 min
|
||||
while (Date.now() - startedAt < TIMEOUT_MS) {
|
||||
await new Promise(r => setTimeout(r, 1500));
|
||||
let progress;
|
||||
try {
|
||||
const resp = await fetch(`${BASE_URL}/video-master/api/progress/${sid}`);
|
||||
progress = await resp.json();
|
||||
} catch (e) {
|
||||
continue; // transient — retry
|
||||
}
|
||||
|
||||
if (progress.message) setSearchButtonText(progress.message);
|
||||
|
||||
if (progress.status === 'failed') {
|
||||
resetSearchButton();
|
||||
showSearchError(progress.message || 'Search failed', folderId, searchedFor);
|
||||
return null;
|
||||
}
|
||||
if (progress.status === 'completed') {
|
||||
const resultResp = await fetch(`${BASE_URL}/video-master/api/search-campaign-result/${sid}`);
|
||||
const result = await resultResp.json();
|
||||
resetSearchButton();
|
||||
if (result.error) {
|
||||
showSearchError(result.error, folderId, searchedFor);
|
||||
return null;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
resetSearchButton();
|
||||
showSearchError('Search timed out after 5 minutes', folderId, searchedFor);
|
||||
return null;
|
||||
}
|
||||
|
||||
document.getElementById('searchForm').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const name = document.getElementById('campaignName').value.trim();
|
||||
|
|
@ -132,63 +194,55 @@
|
|||
const btn = document.getElementById('searchBtn');
|
||||
btn.disabled = true;
|
||||
document.getElementById('searchSpinner').style.display = 'inline-block';
|
||||
document.getElementById('searchBtnText').textContent = 'Searching...';
|
||||
setSearchButtonText('Starting search...');
|
||||
document.getElementById('errorSection').style.display = 'none';
|
||||
document.getElementById('previewSection').style.display = 'none';
|
||||
|
||||
let kickoff;
|
||||
try {
|
||||
const resp = await fetch(`${BASE_URL}/video-master/api/search-campaign`, {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({campaign_name: name})
|
||||
});
|
||||
|
||||
const data = await resp.json();
|
||||
|
||||
if (data.error) {
|
||||
document.getElementById('errorMessage').textContent = data.error;
|
||||
const detailEl = document.getElementById('errorDetail');
|
||||
if (data.campaigns_folder_id && data.searched_for) {
|
||||
detailEl.innerHTML =
|
||||
'Searched for <code>' + data.searched_for + '</code> inside Box folder ' +
|
||||
'<a href="https://app.box.com/folder/' + data.campaigns_folder_id + '" target="_blank">' +
|
||||
'#' + data.campaigns_folder_id + '</a>. ' +
|
||||
'Open the folder in Box to confirm the campaign exists and the name matches.';
|
||||
} else {
|
||||
detailEl.textContent = '';
|
||||
}
|
||||
document.getElementById('errorSection').style.display = 'block';
|
||||
kickoff = await resp.json();
|
||||
if (kickoff.error || !kickoff.session_id) {
|
||||
resetSearchButton();
|
||||
showSearchError(kickoff.error || 'Failed to start search', null, null);
|
||||
return;
|
||||
}
|
||||
|
||||
campaignInfo = data;
|
||||
sessionId = data.session_id;
|
||||
|
||||
// Show preview
|
||||
document.getElementById('campaignFoundName').textContent = data.campaign_name;
|
||||
document.getElementById('mastersCount').textContent = data.masters_count;
|
||||
document.getElementById('adaptationsCount').textContent = data.total_adaptations;
|
||||
|
||||
const countries = Object.keys(data.countries || {});
|
||||
document.getElementById('countriesCount').textContent = countries.length;
|
||||
|
||||
const countriesHtml = countries.map(c => {
|
||||
const count = data.countries[c].count;
|
||||
return `<span class="badge bg-secondary country-badge">${c} (${count})</span>`;
|
||||
}).join(' ');
|
||||
document.getElementById('countriesList').innerHTML =
|
||||
'<strong>Countries:</strong> ' + (countriesHtml || '<span class="text-muted">None found</span>');
|
||||
|
||||
document.getElementById('previewSection').style.display = 'block';
|
||||
|
||||
} catch (err) {
|
||||
document.getElementById('errorMessage').textContent = err.message;
|
||||
document.getElementById('errorSection').style.display = 'block';
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
document.getElementById('searchSpinner').style.display = 'none';
|
||||
document.getElementById('searchBtnText').textContent = 'Search Campaign';
|
||||
resetSearchButton();
|
||||
showSearchError(err.message, null, null);
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await pollSearchProgress(
|
||||
kickoff.session_id,
|
||||
kickoff.campaigns_folder_id,
|
||||
kickoff.searched_for
|
||||
);
|
||||
if (!data) return;
|
||||
|
||||
campaignInfo = data;
|
||||
sessionId = data.session_id;
|
||||
|
||||
// Show preview
|
||||
document.getElementById('campaignFoundName').textContent = data.campaign_name;
|
||||
document.getElementById('mastersCount').textContent = data.masters_count;
|
||||
document.getElementById('adaptationsCount').textContent = data.total_adaptations;
|
||||
|
||||
const countries = Object.keys(data.countries || {});
|
||||
document.getElementById('countriesCount').textContent = countries.length;
|
||||
|
||||
const countriesHtml = countries.map(c => {
|
||||
const count = data.countries[c].count;
|
||||
return `<span class="badge bg-secondary country-badge">${c} (${count})</span>`;
|
||||
}).join(' ');
|
||||
document.getElementById('countriesList').innerHTML =
|
||||
'<strong>Countries:</strong> ' + (countriesHtml || '<span class="text-muted">None found</span>');
|
||||
|
||||
document.getElementById('previewSection').style.display = 'block';
|
||||
});
|
||||
|
||||
async function startMatching() {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue