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:
nickviljoen 2026-05-09 19:52:49 +02:00
parent a500d7b088
commit a3aee0de2e
2 changed files with 175 additions and 68 deletions

View file

@ -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."""

View file

@ -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() {