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 @@
@@ -598,12 +733,117 @@ {% endif %} - @@ -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 %}