diff --git a/blueprints/admin/routes_social.py b/blueprints/admin/routes_social.py index 10744ab..0475286 100644 --- a/blueprints/admin/routes_social.py +++ b/blueprints/admin/routes_social.py @@ -8,16 +8,45 @@ Social media analytics and audit dashboards. import logging from datetime import datetime, timedelta -from flask import render_template, request, redirect, url_for, flash +import threading + +from flask import render_template, request, redirect, url_for, flash, jsonify from flask_login import login_required, current_user from sqlalchemy import func, distinct, or_ from . import bp from database import ( - SessionLocal, Company, Category, CompanySocialMedia, SystemRole + SessionLocal, Company, Category, CompanySocialMedia, SystemRole, + OAuthToken, SocialMediaConfig ) from utils.decorators import role_required, is_audit_owner + +# Data source hierarchy: higher priority sources should not be overwritten by lower +SOURCE_PRIORITY = { + 'facebook_api': 3, + 'manual_edit': 2, + 'manual': 2, + 'website_scrape': 1, + 'brave_search': 0, + None: 0, +} + +SOURCE_LABELS = { + 'facebook_api': {'label': 'Facebook API (OAuth)', 'color': '#22c55e', 'icon': 'api', 'description': 'Dane pobrane bezpośrednio z Facebook Graph API przez autoryzowane połączenie OAuth. Najwyższa wiarygodność danych.'}, + 'manual_edit': {'label': 'Ręczna edycja', 'color': '#6b7280', 'icon': 'manual', 'description': 'URL dodany ręcznie przez menedżera firmy lub administratora w edycji profilu.'}, + 'manual': {'label': 'Ręczna edycja', 'color': '#6b7280', 'icon': 'manual', 'description': 'URL dodany ręcznie przez administratora.'}, + 'website_scrape': {'label': 'Scraping strony www', 'color': '#f59e0b', 'icon': 'scrape', 'description': 'Dane zebrane automatycznie ze strony internetowej firmy. Metryki (followers, engagement) są szacunkowe — mogą różnić się od rzeczywistych.'}, + 'brave_search': {'label': 'Wyszukiwarka', 'color': '#f59e0b', 'icon': 'search', 'description': 'Profil znaleziony przez wyszukiwarkę. Metryki niedostępne.'}, + None: {'label': 'Nieznane', 'color': '#ef4444', 'icon': 'unknown', 'description': 'Brak informacji o źródle danych.'}, +} + +# Which platforms support OAuth +OAUTH_PLATFORMS = { + 'facebook': {'provider': 'meta', 'service': 'facebook'}, + 'instagram': {'provider': 'meta', 'service': 'instagram'}, +} + logger = logging.getLogger(__name__) @@ -518,9 +547,75 @@ def admin_social_audit_detail(company_id): valid_profiles = [p for p in profiles if p.is_valid] invalid_profiles = [p for p in profiles if not p.is_valid] + # Check OAuth status for this company + oauth_tokens = db.query(OAuthToken).filter( + OAuthToken.company_id == company_id, + OAuthToken.is_active == True + ).all() + oauth_status = {} + for token in oauth_tokens: + key = token.service # 'facebook', 'instagram' + oauth_status[key] = { + 'connected': True, + 'provider': token.provider, + 'account_name': token.account_name, + 'expires_at': token.expires_at, + 'expired': token.expires_at < datetime.now() if token.expires_at else False, + } + + # Check SocialMediaConfig (for Facebook page selection) + sm_configs = db.query(SocialMediaConfig).filter( + SocialMediaConfig.company_id == company_id, + SocialMediaConfig.is_active == True + ).all() + for cfg in sm_configs: + platform_key = cfg.platform + if platform_key in oauth_status: + oauth_status[platform_key]['page_configured'] = bool(cfg.page_id) + oauth_status[platform_key]['page_name'] = cfg.page_name + oauth_status[platform_key]['last_sync'] = cfg.updated_at + else: + oauth_status[platform_key] = { + 'connected': False, + 'page_configured': bool(cfg.page_id), + 'page_name': cfg.page_name, + } + # Build per-platform detail platform_details = [] for p in valid_profiles: + source_info = SOURCE_LABELS.get(p.source, SOURCE_LABELS[None]) + oauth_for_platform = oauth_status.get(p.platform, {}) + oauth_available = p.platform in OAUTH_PLATFORMS + + # Determine which fields come from API vs scraping + fields_from_api = [] + fields_from_scraping = [] + fields_empty = [] + + if p.source == 'facebook_api': + if p.followers_count: fields_from_api.append('followers_count') + if p.engagement_rate: fields_from_api.append('engagement_rate') + if p.profile_completeness_score: fields_from_api.append('profile_completeness_score') + if p.has_bio is not None: fields_from_api.append('has_bio') + if p.page_name: fields_from_api.append('page_name') + # These are typically from scraping even with API source + if p.posts_count_30d: fields_from_scraping.append('posts_count_30d') + if p.last_post_date: fields_from_scraping.append('last_post_date') + if p.has_profile_photo is not None: fields_from_scraping.append('has_profile_photo') + elif p.source in ('website_scrape', 'brave_search'): + for field_name, field_val in [ + ('followers_count', p.followers_count), + ('engagement_rate', p.engagement_rate), + ('posts_count_30d', p.posts_count_30d), + ('last_post_date', p.last_post_date), + ('profile_completeness_score', p.profile_completeness_score), + ]: + if field_val: + fields_from_scraping.append(field_name) + else: + fields_empty.append(field_name) + detail = { 'platform': p.platform, 'url': p.url, @@ -541,7 +636,24 @@ def admin_social_audit_detail(company_id): 'verified_at': p.verified_at, 'source': p.source, 'check_status': p.check_status, - 'is_valid': p.is_valid + 'is_valid': p.is_valid, + 'last_checked_at': p.last_checked_at, + # Data provenance + 'source_label': source_info['label'], + 'source_color': source_info['color'], + 'source_icon': source_info['icon'], + 'source_description': source_info['description'], + 'source_priority': SOURCE_PRIORITY.get(p.source, 0), + 'fields_from_api': fields_from_api, + 'fields_from_scraping': fields_from_scraping, + 'fields_empty': fields_empty, + # OAuth info + 'oauth_available': oauth_available, + 'oauth_connected': oauth_for_platform.get('connected', False), + 'oauth_expired': oauth_for_platform.get('expired', False), + 'oauth_page_configured': oauth_for_platform.get('page_configured', False), + 'oauth_account_name': oauth_for_platform.get('account_name'), + 'oauth_last_sync': oauth_for_platform.get('last_sync'), } # Computed Tier 1 metrics growth_rate, growth_trend = _compute_followers_growth(p.followers_history or []) @@ -621,7 +733,207 @@ def admin_social_audit_detail(company_id): invalid_profiles=invalid_profiles, recommendations=recommendations, company_scores=company_scores, + oauth_status=oauth_status, + oauth_platforms=OAUTH_PLATFORMS, now=datetime.now() ) finally: db.close() + + +# ============================================================ +# SOCIAL MEDIA ENRICHMENT (run scraper from dashboard) +# ============================================================ + +# In-memory status tracker for enrichment jobs +_enrichment_status = { + 'running': False, + 'progress': 0, + 'total': 0, + 'completed': 0, + 'errors': 0, + 'last_run': None, + 'results': [], +} + + +def _run_enrichment_background(company_ids): + """Run social media profile enrichment in background thread.""" + import sys + from pathlib import Path + + # Import enricher from scripts + sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent / 'scripts')) + try: + from social_media_audit import SocialProfileEnricher + except ImportError: + logger.error("Could not import SocialProfileEnricher from scripts/social_media_audit.py") + _enrichment_status['running'] = False + return + + enricher = SocialProfileEnricher() + db = SessionLocal() + _enrichment_status['total'] = len(company_ids) + _enrichment_status['completed'] = 0 + _enrichment_status['errors'] = 0 + _enrichment_status['results'] = [] + + try: + for company_id in company_ids: + try: + company = db.query(Company).filter_by(id=company_id).first() + if not company: + continue + + profiles = db.query(CompanySocialMedia).filter( + CompanySocialMedia.company_id == company_id, + CompanySocialMedia.is_valid == True + ).all() + + company_results = [] + for profile in profiles: + # Skip if source is from API (higher priority) + if profile.source in ('facebook_api',): + company_results.append({ + 'platform': profile.platform, + 'status': 'skipped', + 'reason': 'API data (higher priority)', + }) + continue + + try: + enriched = enricher.enrich_profile(profile.platform, profile.url) + if enriched: + if enriched.get('page_name'): + profile.page_name = enriched['page_name'] + if enriched.get('followers_count') is not None: + profile.followers_count = enriched['followers_count'] + if enriched.get('has_profile_photo') is not None: + profile.has_profile_photo = enriched['has_profile_photo'] + if enriched.get('has_cover_photo') is not None: + profile.has_cover_photo = enriched['has_cover_photo'] + if enriched.get('has_bio') is not None: + profile.has_bio = enriched['has_bio'] + if enriched.get('profile_description'): + profile.profile_description = enriched['profile_description'] + if enriched.get('posts_count_30d') is not None: + profile.posts_count_30d = enriched['posts_count_30d'] + if enriched.get('posts_count_365d') is not None: + profile.posts_count_365d = enriched['posts_count_365d'] + if enriched.get('last_post_date') is not None: + profile.last_post_date = enriched['last_post_date'] + if enriched.get('engagement_rate') is not None: + profile.engagement_rate = enriched['engagement_rate'] + if enriched.get('posting_frequency_score') is not None: + profile.posting_frequency_score = enriched['posting_frequency_score'] + if enriched.get('profile_completeness_score') is not None: + profile.profile_completeness_score = enriched['profile_completeness_score'] + + profile.last_checked_at = datetime.now() + db.commit() + + company_results.append({ + 'platform': profile.platform, + 'status': 'enriched', + 'fields': list(enriched.keys()), + }) + else: + profile.last_checked_at = datetime.now() + db.commit() + company_results.append({ + 'platform': profile.platform, + 'status': 'no_data', + }) + except Exception as e: + logger.warning(f"Enrichment failed for {company.name}/{profile.platform}: {e}") + company_results.append({ + 'platform': profile.platform, + 'status': 'error', + 'error': str(e)[:100], + }) + _enrichment_status['errors'] += 1 + + _enrichment_status['results'].append({ + 'company_id': company_id, + 'company_name': company.name, + 'profiles': company_results, + }) + + except Exception as e: + logger.error(f"Enrichment error for company {company_id}: {e}") + _enrichment_status['errors'] += 1 + + _enrichment_status['completed'] += 1 + _enrichment_status['progress'] = round( + _enrichment_status['completed'] / _enrichment_status['total'] * 100 + ) + finally: + db.close() + _enrichment_status['running'] = False + _enrichment_status['last_run'] = datetime.now() + logger.info(f"Enrichment completed: {_enrichment_status['completed']}/{_enrichment_status['total']}, errors: {_enrichment_status['errors']}") + + +@bp.route('/social-audit/run-enrichment', methods=['POST']) +@login_required +@role_required(SystemRole.ADMIN) +def admin_social_audit_run_enrichment(): + """Start social media profile enrichment for selected or all companies.""" + if not is_audit_owner(): + return jsonify({'error': 'Brak uprawnień'}), 403 + + if _enrichment_status['running']: + return jsonify({ + 'error': 'Enrichment już działa', + 'progress': _enrichment_status['progress'], + 'completed': _enrichment_status['completed'], + 'total': _enrichment_status['total'], + }), 409 + + company_ids_param = request.form.get('company_ids', '') + if company_ids_param: + company_ids = [int(x) for x in company_ids_param.split(',') if x.strip().isdigit()] + else: + db = SessionLocal() + try: + company_ids = [row[0] for row in db.query( + distinct(CompanySocialMedia.company_id) + ).filter(CompanySocialMedia.is_valid == True).all()] + finally: + db.close() + + if not company_ids: + return jsonify({'error': 'Brak firm do audytu'}), 400 + + _enrichment_status['running'] = True + _enrichment_status['progress'] = 0 + _enrichment_status['results'] = [] + + thread = threading.Thread( + target=_run_enrichment_background, + args=(company_ids,), + daemon=True + ) + thread.start() + + return jsonify({ + 'status': 'started', + 'total': len(company_ids), + 'message': f'Rozpoczęto audyt {len(company_ids)} firm w tle.', + }) + + +@bp.route('/social-audit/enrichment-status') +@login_required +@role_required(SystemRole.OFFICE_MANAGER) +def admin_social_audit_enrichment_status(): + """Get current enrichment job status.""" + return jsonify({ + 'running': _enrichment_status['running'], + 'progress': _enrichment_status['progress'], + 'completed': _enrichment_status['completed'], + 'total': _enrichment_status['total'], + 'errors': _enrichment_status['errors'], + 'last_run': _enrichment_status['last_run'].strftime('%d.%m.%Y %H:%M') if _enrichment_status['last_run'] else None, + 'results': _enrichment_status['results'][-10:], + }) diff --git a/scripts/social_media_audit.py b/scripts/social_media_audit.py index 865b69a..29924cf 100644 --- a/scripts/social_media_audit.py +++ b/scripts/social_media_audit.py @@ -1644,20 +1644,44 @@ class SocialMediaAuditor: ) ON CONFLICT (company_id, platform, url) DO UPDATE SET verified_at = EXCLUDED.verified_at, - source = EXCLUDED.source, is_valid = EXCLUDED.is_valid, - page_name = COALESCE(EXCLUDED.page_name, company_social_media.page_name), - followers_count = COALESCE(EXCLUDED.followers_count, company_social_media.followers_count), + last_checked_at = NOW(), + -- Don't overwrite source if existing record is from a higher-priority source + source = CASE + WHEN company_social_media.source IN ('facebook_api') THEN company_social_media.source + ELSE EXCLUDED.source + END, + -- Don't overwrite metrics if existing record is from API (higher priority) + page_name = CASE + WHEN company_social_media.source IN ('facebook_api') THEN company_social_media.page_name + ELSE COALESCE(EXCLUDED.page_name, company_social_media.page_name) + END, + followers_count = CASE + WHEN company_social_media.source IN ('facebook_api') THEN company_social_media.followers_count + ELSE COALESCE(EXCLUDED.followers_count, company_social_media.followers_count) + END, has_profile_photo = COALESCE(EXCLUDED.has_profile_photo, company_social_media.has_profile_photo), has_cover_photo = COALESCE(EXCLUDED.has_cover_photo, company_social_media.has_cover_photo), - has_bio = COALESCE(EXCLUDED.has_bio, company_social_media.has_bio), - profile_description = COALESCE(EXCLUDED.profile_description, company_social_media.profile_description), + has_bio = CASE + WHEN company_social_media.source IN ('facebook_api') THEN company_social_media.has_bio + ELSE COALESCE(EXCLUDED.has_bio, company_social_media.has_bio) + END, + profile_description = CASE + WHEN company_social_media.source IN ('facebook_api') THEN company_social_media.profile_description + ELSE COALESCE(EXCLUDED.profile_description, company_social_media.profile_description) + END, posts_count_30d = COALESCE(EXCLUDED.posts_count_30d, company_social_media.posts_count_30d), posts_count_365d = COALESCE(EXCLUDED.posts_count_365d, company_social_media.posts_count_365d), - engagement_rate = COALESCE(EXCLUDED.engagement_rate, company_social_media.engagement_rate), + engagement_rate = CASE + WHEN company_social_media.source IN ('facebook_api') THEN company_social_media.engagement_rate + ELSE COALESCE(EXCLUDED.engagement_rate, company_social_media.engagement_rate) + END, posting_frequency_score = COALESCE(EXCLUDED.posting_frequency_score, company_social_media.posting_frequency_score), last_post_date = COALESCE(EXCLUDED.last_post_date, company_social_media.last_post_date), - profile_completeness_score = COALESCE(EXCLUDED.profile_completeness_score, company_social_media.profile_completeness_score), + profile_completeness_score = CASE + WHEN company_social_media.source IN ('facebook_api') THEN company_social_media.profile_completeness_score + ELSE COALESCE(EXCLUDED.profile_completeness_score, company_social_media.profile_completeness_score) + END, updated_at = NOW() """) diff --git a/templates/admin/social_audit_dashboard.html b/templates/admin/social_audit_dashboard.html index e0e31b4..a807290 100644 --- a/templates/admin/social_audit_dashboard.html +++ b/templates/admin/social_audit_dashboard.html @@ -517,6 +517,16 @@
+ + @@ -977,4 +987,62 @@ function resetFilters() { document.getElementById('filterSearch').value = ''; applyFilters(); } + +// Enrichment +function startEnrichment() { + if (!confirm('Uruchomić audyt social media dla wszystkich firm?\n\nProces działa w tle i może potrwać kilka minut.\nDane z API (OAuth) nie zostaną nadpisane.')) return; + + var btn = document.getElementById('enrichBtn'); + var progress = document.getElementById('enrichProgress'); + btn.disabled = true; + btn.textContent = 'Uruchamianie...'; + progress.style.display = 'inline-flex'; + + fetch('{{ url_for("admin.admin_social_audit_run_enrichment") }}', { + method: 'POST', + headers: {'Content-Type': 'application/x-www-form-urlencoded', 'X-CSRFToken': '{{ csrf_token() }}'}, + }) + .then(function(r) { return r.json(); }) + .then(function(data) { + if (data.status === 'started') { + document.getElementById('enrichText').textContent = 'Audyt: 0/' + data.total; + pollEnrichment(); + } else { + alert(data.error || 'Błąd uruchamiania'); + btn.disabled = false; + btn.textContent = 'Uruchom audyt'; + progress.style.display = 'none'; + } + }) + .catch(function(e) { + alert('Błąd: ' + e.message); + btn.disabled = false; + btn.textContent = 'Uruchom audyt'; + progress.style.display = 'none'; + }); +} + +function pollEnrichment() { + fetch('{{ url_for("admin.admin_social_audit_enrichment_status") }}') + .then(function(r) { return r.json(); }) + .then(function(data) { + document.getElementById('enrichPct').textContent = data.progress + '%'; + document.getElementById('enrichText').textContent = 'Audyt: ' + data.completed + '/' + data.total; + + if (data.running) { + setTimeout(pollEnrichment, 3000); + } else { + var btn = document.getElementById('enrichBtn'); + btn.disabled = false; + btn.innerHTML = ' Uruchom audyt'; + + var errInfo = data.errors > 0 ? ', ' + data.errors + ' błędów' : ''; + document.getElementById('enrichText').textContent = 'Zakończono: ' + data.completed + '/' + data.total + errInfo; + + setTimeout(function() { + document.getElementById('enrichProgress').style.display = 'none'; + }, 10000); + } + }); +} {% endblock %} diff --git a/templates/admin/social_audit_detail.html b/templates/admin/social_audit_detail.html index 7d0e369..535fd26 100644 --- a/templates/admin/social_audit_detail.html +++ b/templates/admin/social_audit_detail.html @@ -244,6 +244,137 @@ color: var(--text-secondary); } + /* Data provenance */ + .provenance-section { + margin-top: var(--spacing-md); + padding: var(--spacing-md); + background: var(--background); + border-radius: var(--radius); + border: 1px solid var(--border-color, #e5e7eb); + } + + .provenance-header { + display: flex; + align-items: center; + gap: var(--spacing-sm); + margin-bottom: var(--spacing-sm); + } + + .source-badge { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 3px 10px; + border-radius: 12px; + font-size: 11px; + font-weight: 600; + color: white; + } + + .provenance-details { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: var(--spacing-xs) var(--spacing-md); + font-size: var(--font-size-xs); + color: var(--text-secondary); + } + + .provenance-detail { + display: flex; + align-items: center; + gap: 4px; + } + + .provenance-detail .label { + font-weight: 500; + color: var(--text-primary); + } + + .field-source-list { + display: flex; + flex-wrap: wrap; + gap: 4px; + margin-top: var(--spacing-xs); + } + + .field-tag { + display: inline-flex; + align-items: center; + gap: 2px; + padding: 1px 6px; + border-radius: 4px; + font-size: 10px; + font-weight: 500; + } + + .field-tag.api { background: #dcfce7; color: #15803d; } + .field-tag.scrape { background: #fef3c7; color: #92400e; } + .field-tag.empty { background: #fee2e2; color: #991b1b; } + + .oauth-prompt { + display: flex; + align-items: center; + gap: var(--spacing-sm); + margin-top: var(--spacing-sm); + padding: var(--spacing-sm) var(--spacing-md); + background: #eff6ff; + border: 1px solid #bfdbfe; + border-radius: var(--radius); + font-size: var(--font-size-xs); + } + + .oauth-prompt a { + color: #2563eb; + font-weight: 600; + text-decoration: none; + } + + .oauth-prompt a:hover { + text-decoration: underline; + } + + .oauth-prompt.connected { + background: #f0fdf4; + border-color: #bbf7d0; + } + + .oauth-prompt.expired { + background: #fef2f2; + border-color: #fecaca; + } + + .sync-btn { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 3px 10px; + background: #2563eb; + color: white; + border: none; + border-radius: var(--radius); + font-size: 11px; + font-weight: 500; + cursor: pointer; + text-decoration: none; + } + + .sync-btn:hover { + background: #1d4ed8; + } + + .provenance-toggle { + font-size: 10px; + color: var(--text-secondary); + cursor: pointer; + background: none; + border: none; + padding: 0; + text-decoration: underline; + margin-left: auto; + } + + .hidden { display: none !important; } + /* Invalid profiles */ .invalid-section { background: #fef2f2; @@ -376,6 +507,10 @@ {% endif %}
+ Profil firmy
@@ -598,12 +733,117 @@ {% endif %} -
- Źródło: {{ p.source or 'nieznane' }} - {% if p.verified_at %} - Zweryfikowano: {{ p.verified_at.strftime('%d.%m.%Y %H:%M') }} + +
+
+ + {% if p.source_icon == 'api' %} + + {% elif p.source_icon == 'scrape' %} + + {% elif p.source_icon == 'manual' %} + + {% else %} + + {% endif %} + {{ p.source_label }} + + + Priorytet: {{ p.source_priority }}/3 + + +
+ +
+ {% if p.verified_at %} +
+ Zweryfikowano: + {{ p.verified_at.strftime('%d.%m.%Y %H:%M') }} +
+ {% endif %} + {% if p.last_checked_at %} +
+ Ostatni check: + {{ p.last_checked_at.strftime('%d.%m.%Y %H:%M') }} +
+ {% endif %} +
+ Status HTTP: + {{ p.check_status or 'ok' }} +
+
+ + + + + + {% if p.oauth_available %} + {% if p.oauth_connected and not p.oauth_expired %} +
+ + OAuth połączony + {% if p.oauth_account_name %} + ({{ p.oauth_account_name }}) + {% endif %} + {% if p.oauth_page_configured %} + ✓ Strona skonfigurowana + {% else %} + Wybierz stronę → + {% endif %} + {% if p.oauth_last_sync %} + Sync: {{ p.oauth_last_sync.strftime('%d.%m.%Y') }} + {% endif %} +
+ {% elif p.oauth_connected and p.oauth_expired %} +
+ + Token OAuth wygasł + Odnów połączenie → +
+ {% else %} +
+ + Połącz {{ p.platform|capitalize }} API przez OAuth, aby uzyskać dokładne dane (followers, engagement, insights). + Połącz OAuth → +
+ {% endif %} + {% else %} +
+ + OAuth API niedostępne dla {{ p.platform|capitalize }}. Dane pochodzą ze scrapingu publicznych profili. +
{% endif %} - Status: {{ p.check_status or 'ok' }}
@@ -632,4 +872,44 @@ {% endif %} + +{% block extra_js %} +function runSingleEnrichment(companyId) { + var btn = document.getElementById('enrichSingleBtn'); + btn.disabled = true; + btn.textContent = 'Audytowanie...'; + + fetch('{{ url_for("admin.admin_social_audit_run_enrichment") }}', { + method: 'POST', + headers: {'Content-Type': 'application/x-www-form-urlencoded', 'X-CSRFToken': '{{ csrf_token() }}'}, + body: 'company_ids=' + companyId + }) + .then(function(r) { return r.json(); }) + .then(function(data) { + if (data.status === 'started') { + pollSingleEnrichment(); + } else { + alert(data.error || 'Błąd'); + btn.disabled = false; + btn.textContent = 'Uruchom audyt'; + } + }) + .catch(function(e) { + alert('Błąd: ' + e.message); + btn.disabled = false; + btn.textContent = 'Uruchom audyt'; + }); +} + +function pollSingleEnrichment() { + fetch('{{ url_for("admin.admin_social_audit_enrichment_status") }}') + .then(function(r) { return r.json(); }) + .then(function(data) { + if (data.running) { + setTimeout(pollSingleEnrichment, 2000); + } else { + location.reload(); + } + }); +} {% endblock %}