feat: dual data sources for social audit with provenance indicators
Some checks are pending
NordaBiz Tests / Unit & Integration Tests (push) Waiting to run
NordaBiz Tests / E2E Tests (Playwright) (push) Blocked by required conditions
NordaBiz Tests / Smoke Tests (Production) (push) Blocked by required conditions
NordaBiz Tests / Send Failure Notification (push) Blocked by required conditions
Some checks are pending
NordaBiz Tests / Unit & Integration Tests (push) Waiting to run
NordaBiz Tests / E2E Tests (Playwright) (push) Blocked by required conditions
NordaBiz Tests / Smoke Tests (Production) (push) Blocked by required conditions
NordaBiz Tests / Send Failure Notification (push) Blocked by required conditions
- Scraper no longer overwrites API data (source priority hierarchy) - Per-platform data provenance badges (API OAuth/Scraping/Manual/Unknown) - Expandable field-level source breakdown (which fields from API vs scraping) - OAuth status per platform with connect/renew/sync links - "Run audit" button on dashboard (background enrichment for all companies) - "Run audit" button on detail view (single company enrichment) - Enrichment progress polling with real-time status updates Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
4cbb2df7ae
commit
d3c81e4880
@ -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:],
|
||||
})
|
||||
|
||||
@ -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()
|
||||
""")
|
||||
|
||||
|
||||
@ -517,6 +517,16 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<button id="enrichBtn" class="btn btn-primary btn-sm" onclick="startEnrichment()" title="Uruchom scraping publicznych profili social media dla wszystkich firm">
|
||||
<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
|
||||
</svg>
|
||||
Uruchom audyt
|
||||
</button>
|
||||
<div id="enrichProgress" style="display: none; font-size: 12px; padding: 4px 12px; background: #eff6ff; border-radius: var(--radius); color: #2563eb;">
|
||||
<span id="enrichText">Audyt...</span>
|
||||
<span id="enrichPct">0%</span>
|
||||
</div>
|
||||
<a href="{{ url_for('admin.admin_social_media') }}" class="btn btn-outline btn-sm">
|
||||
<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"/>
|
||||
@ -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 = '<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/></svg> 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 %}
|
||||
|
||||
@ -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 %}
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button id="enrichSingleBtn" class="btn btn-primary btn-sm" onclick="runSingleEnrichment({{ company.id }})">
|
||||
<svg width="14" height="14" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/></svg>
|
||||
Uruchom audyt
|
||||
</button>
|
||||
<a href="{{ url_for('company_detail', company_id=company.id) }}" class="btn btn-outline btn-sm">Profil firmy</a>
|
||||
</div>
|
||||
</div>
|
||||
@ -598,12 +733,117 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="meta-info">
|
||||
<span>Źródło: {{ p.source or 'nieznane' }}</span>
|
||||
{% if p.verified_at %}
|
||||
<span>Zweryfikowano: {{ p.verified_at.strftime('%d.%m.%Y %H:%M') }}</span>
|
||||
<!-- Data Provenance Section -->
|
||||
<div class="provenance-section">
|
||||
<div class="provenance-header">
|
||||
<span class="source-badge" style="background: {{ p.source_color }};">
|
||||
{% if p.source_icon == 'api' %}
|
||||
<svg width="12" height="12" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"/></svg>
|
||||
{% elif p.source_icon == 'scrape' %}
|
||||
<svg width="12" height="12" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9"/></svg>
|
||||
{% elif p.source_icon == 'manual' %}
|
||||
<svg width="12" height="12" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>
|
||||
{% else %}
|
||||
<svg width="12" height="12" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
|
||||
{% endif %}
|
||||
{{ p.source_label }}
|
||||
</span>
|
||||
<span style="font-size: 10px; color: var(--text-secondary);" title="{{ p.source_description }}">
|
||||
Priorytet: {{ p.source_priority }}/3
|
||||
</span>
|
||||
<button class="provenance-toggle" onclick="this.closest('.provenance-section').querySelector('.provenance-expanded').classList.toggle('hidden')">
|
||||
szczegóły ▾
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="provenance-details">
|
||||
{% if p.verified_at %}
|
||||
<div class="provenance-detail">
|
||||
<span class="label">Zweryfikowano:</span>
|
||||
{{ p.verified_at.strftime('%d.%m.%Y %H:%M') }}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if p.last_checked_at %}
|
||||
<div class="provenance-detail">
|
||||
<span class="label">Ostatni check:</span>
|
||||
{{ p.last_checked_at.strftime('%d.%m.%Y %H:%M') }}
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="provenance-detail">
|
||||
<span class="label">Status HTTP:</span>
|
||||
{{ p.check_status or 'ok' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Expanded details (hidden by default) -->
|
||||
<div class="provenance-expanded hidden" style="margin-top: var(--spacing-sm);">
|
||||
<div style="font-size: 11px; color: var(--text-secondary); margin-bottom: var(--spacing-xs); font-style: italic;">
|
||||
{{ p.source_description }}
|
||||
</div>
|
||||
|
||||
{% if p.fields_from_api or p.fields_from_scraping or p.fields_empty %}
|
||||
<div style="font-size: 11px; font-weight: 500; margin-bottom: 4px;">Pochodzenie danych per pole:</div>
|
||||
<div class="field-source-list">
|
||||
{% set field_names = {
|
||||
'followers_count': 'Followers',
|
||||
'engagement_rate': 'Engagement',
|
||||
'profile_completeness_score': 'Kompletność',
|
||||
'posts_count_30d': 'Posty 30d',
|
||||
'last_post_date': 'Ostatni post',
|
||||
'has_bio': 'Bio',
|
||||
'page_name': 'Nazwa',
|
||||
'has_profile_photo': 'Zdjęcie'
|
||||
} %}
|
||||
{% for f in p.fields_from_api %}
|
||||
<span class="field-tag api" title="Dane z autoryzowanego API">✓ {{ field_names.get(f, f) }}</span>
|
||||
{% endfor %}
|
||||
{% for f in p.fields_from_scraping %}
|
||||
<span class="field-tag scrape" title="Dane ze scrapingu (szacunkowe)">~ {{ field_names.get(f, f) }}</span>
|
||||
{% endfor %}
|
||||
{% for f in p.fields_empty %}
|
||||
<span class="field-tag empty" title="Brak danych — wymaga audytu lub połączenia OAuth">✗ {{ field_names.get(f, f) }}</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- OAuth status / prompt -->
|
||||
{% if p.oauth_available %}
|
||||
{% if p.oauth_connected and not p.oauth_expired %}
|
||||
<div class="oauth-prompt connected">
|
||||
<svg width="14" height="14" fill="none" stroke="#15803d" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"/></svg>
|
||||
<span style="color: #15803d; font-weight: 500;">OAuth połączony</span>
|
||||
{% if p.oauth_account_name %}
|
||||
<span style="color: var(--text-secondary);">({{ p.oauth_account_name }})</span>
|
||||
{% endif %}
|
||||
{% if p.oauth_page_configured %}
|
||||
<span style="color: #15803d;">✓ Strona skonfigurowana</span>
|
||||
{% else %}
|
||||
<a href="{{ url_for('admin.social_publisher_company_settings', company_id=company.id) }}">Wybierz stronę →</a>
|
||||
{% endif %}
|
||||
{% if p.oauth_last_sync %}
|
||||
<span style="margin-left: auto; color: var(--text-secondary);">Sync: {{ p.oauth_last_sync.strftime('%d.%m.%Y') }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% elif p.oauth_connected and p.oauth_expired %}
|
||||
<div class="oauth-prompt expired">
|
||||
<svg width="14" height="14" fill="none" stroke="#dc2626" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z"/></svg>
|
||||
<span style="color: #dc2626; font-weight: 500;">Token OAuth wygasł</span>
|
||||
<a href="{{ url_for('admin.social_publisher_company_settings', company_id=company.id) }}">Odnów połączenie →</a>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="oauth-prompt">
|
||||
<svg width="14" height="14" fill="none" stroke="#2563eb" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"/></svg>
|
||||
<span>Połącz <strong>{{ p.platform|capitalize }} API</strong> przez OAuth, aby uzyskać dokładne dane (followers, engagement, insights).</span>
|
||||
<a href="{{ url_for('admin.social_publisher_company_settings', company_id=company.id) }}">Połącz OAuth →</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<div class="oauth-prompt" style="background: #f9fafb; border-color: #e5e7eb;">
|
||||
<svg width="14" height="14" fill="none" stroke="#9ca3af" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
|
||||
<span style="color: var(--text-secondary);">OAuth API niedostępne dla {{ p.platform|capitalize }}. Dane pochodzą ze scrapingu publicznych profili.</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
<span>Status: {{ p.check_status or 'ok' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -632,4 +872,44 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% 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 %}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user