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

- 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:
Maciej Pienczyn 2026-03-12 08:37:02 +01:00
parent 4cbb2df7ae
commit d3c81e4880
4 changed files with 699 additions and 15 deletions

View File

@ -8,16 +8,45 @@ Social media analytics and audit dashboards.
import logging import logging
from datetime import datetime, timedelta 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 flask_login import login_required, current_user
from sqlalchemy import func, distinct, or_ from sqlalchemy import func, distinct, or_
from . import bp from . import bp
from database import ( from database import (
SessionLocal, Company, Category, CompanySocialMedia, SystemRole SessionLocal, Company, Category, CompanySocialMedia, SystemRole,
OAuthToken, SocialMediaConfig
) )
from utils.decorators import role_required, is_audit_owner 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__) 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] valid_profiles = [p for p in profiles if p.is_valid]
invalid_profiles = [p for p in profiles if not 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 # Build per-platform detail
platform_details = [] platform_details = []
for p in valid_profiles: 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 = { detail = {
'platform': p.platform, 'platform': p.platform,
'url': p.url, 'url': p.url,
@ -541,7 +636,24 @@ def admin_social_audit_detail(company_id):
'verified_at': p.verified_at, 'verified_at': p.verified_at,
'source': p.source, 'source': p.source,
'check_status': p.check_status, '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 # Computed Tier 1 metrics
growth_rate, growth_trend = _compute_followers_growth(p.followers_history or []) 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, invalid_profiles=invalid_profiles,
recommendations=recommendations, recommendations=recommendations,
company_scores=company_scores, company_scores=company_scores,
oauth_status=oauth_status,
oauth_platforms=OAUTH_PLATFORMS,
now=datetime.now() now=datetime.now()
) )
finally: finally:
db.close() 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:],
})

View File

@ -1644,20 +1644,44 @@ class SocialMediaAuditor:
) )
ON CONFLICT (company_id, platform, url) DO UPDATE SET ON CONFLICT (company_id, platform, url) DO UPDATE SET
verified_at = EXCLUDED.verified_at, verified_at = EXCLUDED.verified_at,
source = EXCLUDED.source,
is_valid = EXCLUDED.is_valid, is_valid = EXCLUDED.is_valid,
page_name = COALESCE(EXCLUDED.page_name, company_social_media.page_name), last_checked_at = NOW(),
followers_count = COALESCE(EXCLUDED.followers_count, company_social_media.followers_count), -- 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_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_cover_photo = COALESCE(EXCLUDED.has_cover_photo, company_social_media.has_cover_photo),
has_bio = COALESCE(EXCLUDED.has_bio, company_social_media.has_bio), has_bio = CASE
profile_description = COALESCE(EXCLUDED.profile_description, company_social_media.profile_description), 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_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), 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), 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), 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() updated_at = NOW()
""") """)

View File

@ -517,6 +517,16 @@
</div> </div>
</div> </div>
<div class="header-actions"> <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"> <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"> <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"/> <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 = ''; document.getElementById('filterSearch').value = '';
applyFilters(); 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 %} {% endblock %}

View File

@ -244,6 +244,137 @@
color: var(--text-secondary); 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 profiles */
.invalid-section { .invalid-section {
background: #fef2f2; background: #fef2f2;
@ -376,6 +507,10 @@
{% endif %} {% endif %}
</div> </div>
<div class="actions"> <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> <a href="{{ url_for('company_detail', company_id=company.id) }}" class="btn btn-outline btn-sm">Profil firmy</a>
</div> </div>
</div> </div>
@ -598,12 +733,117 @@
</div> </div>
{% endif %} {% endif %}
<div class="meta-info"> <!-- Data Provenance Section -->
<span>Źródło: {{ p.source or 'nieznane' }}</span> <div class="provenance-section">
{% if p.verified_at %} <div class="provenance-header">
<span>Zweryfikowano: {{ p.verified_at.strftime('%d.%m.%Y %H:%M') }}</span> <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 &#9662;
</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">&#10003; {{ 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">&#10007; {{ 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;">&#10003; Strona skonfigurowana</span>
{% else %}
<a href="{{ url_for('admin.social_publisher_company_settings', company_id=company.id) }}">Wybierz stronę &rarr;</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 &rarr;</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 &rarr;</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 %} {% endif %}
<span>Status: {{ p.check_status or 'ok' }}</span>
</div> </div>
</div> </div>
</div> </div>
@ -632,4 +872,44 @@
</div> </div>
{% endif %} {% endif %}
</div> </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 %} {% endblock %}