feat: enrichment review/approve flow - no data saved without admin approval
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 now collects data to staging area (in-memory), NOT to database - New review page (/admin/social-audit/enrichment-review) with: - Summary stats (scanned, changes, skipped, errors) - Per-company expandable sections with before→after diff per field - Pending approval banner with change count - Sticky bottom bar with Approve/Discard buttons - Approve endpoint writes staged data to DB - Discard endpoint clears staging without touching DB - After scan completes, auto-redirect to review page - Companies without changes shown in collapsed list Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
d3c81e4880
commit
a63d74ad3a
@ -742,10 +742,26 @@ def admin_social_audit_detail(company_id):
|
||||
|
||||
|
||||
# ============================================================
|
||||
# SOCIAL MEDIA ENRICHMENT (run scraper from dashboard)
|
||||
# SOCIAL MEDIA ENRICHMENT (collect → review → approve/reject)
|
||||
# ============================================================
|
||||
|
||||
# In-memory status tracker for enrichment jobs
|
||||
# Field labels for display
|
||||
_FIELD_LABELS = {
|
||||
'page_name': 'Nazwa strony',
|
||||
'followers_count': 'Obserwujący',
|
||||
'has_profile_photo': 'Zdjęcie profilowe',
|
||||
'has_cover_photo': 'Zdjęcie w tle',
|
||||
'has_bio': 'Bio/opis',
|
||||
'profile_description': 'Opis profilu',
|
||||
'posts_count_30d': 'Postów (30 dni)',
|
||||
'posts_count_365d': 'Postów (rok)',
|
||||
'last_post_date': 'Ostatni post',
|
||||
'engagement_rate': 'Engagement rate',
|
||||
'posting_frequency_score': 'Regularność',
|
||||
'profile_completeness_score': 'Kompletność profilu',
|
||||
}
|
||||
|
||||
# In-memory staging area — data collected but NOT yet saved to DB
|
||||
_enrichment_status = {
|
||||
'running': False,
|
||||
'progress': 0,
|
||||
@ -753,21 +769,43 @@ _enrichment_status = {
|
||||
'completed': 0,
|
||||
'errors': 0,
|
||||
'last_run': None,
|
||||
'results': [],
|
||||
'results': [], # Per-company results with before/after diffs
|
||||
'pending_changes': [], # Changes awaiting approval
|
||||
'approved': False,
|
||||
}
|
||||
|
||||
|
||||
def _format_value(key, val):
|
||||
"""Format a field value for display."""
|
||||
if val is None:
|
||||
return '—'
|
||||
if isinstance(val, bool):
|
||||
return 'Tak' if val else 'Nie'
|
||||
if key == 'last_post_date' and hasattr(val, 'strftime'):
|
||||
return val.strftime('%d.%m.%Y')
|
||||
if key == 'engagement_rate':
|
||||
return f'{val}%'
|
||||
if key == 'posting_frequency_score':
|
||||
return f'{val}/10'
|
||||
if key == 'profile_completeness_score':
|
||||
return f'{val}%'
|
||||
if key == 'followers_count' and isinstance(val, (int, float)):
|
||||
return f'{int(val):,}'.replace(',', ' ')
|
||||
if key == 'profile_description' and isinstance(val, str) and len(val) > 80:
|
||||
return val[:80] + '...'
|
||||
return str(val)
|
||||
|
||||
|
||||
def _run_enrichment_background(company_ids):
|
||||
"""Run social media profile enrichment in background thread."""
|
||||
"""Collect enrichment data into staging area (NO database writes)."""
|
||||
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")
|
||||
logger.error("Could not import SocialProfileEnricher")
|
||||
_enrichment_status['running'] = False
|
||||
return
|
||||
|
||||
@ -777,6 +815,10 @@ def _run_enrichment_background(company_ids):
|
||||
_enrichment_status['completed'] = 0
|
||||
_enrichment_status['errors'] = 0
|
||||
_enrichment_status['results'] = []
|
||||
_enrichment_status['pending_changes'] = []
|
||||
_enrichment_status['approved'] = False
|
||||
|
||||
tracked_fields = list(_FIELD_LABELS.keys())
|
||||
|
||||
try:
|
||||
for company_id in company_ids:
|
||||
@ -790,74 +832,80 @@ def _run_enrichment_background(company_ids):
|
||||
CompanySocialMedia.is_valid == True
|
||||
).all()
|
||||
|
||||
company_results = []
|
||||
company_result = {
|
||||
'company_id': company_id,
|
||||
'company_name': company.name,
|
||||
'profiles': [],
|
||||
'has_changes': False,
|
||||
}
|
||||
|
||||
for profile in profiles:
|
||||
# Skip if source is from API (higher priority)
|
||||
profile_result = {
|
||||
'profile_id': profile.id,
|
||||
'platform': profile.platform,
|
||||
'url': profile.url,
|
||||
'source': profile.source,
|
||||
}
|
||||
|
||||
# Skip API-sourced profiles
|
||||
if profile.source in ('facebook_api',):
|
||||
company_results.append({
|
||||
'platform': profile.platform,
|
||||
'status': 'skipped',
|
||||
'reason': 'API data (higher priority)',
|
||||
})
|
||||
profile_result['status'] = 'skipped'
|
||||
profile_result['reason'] = 'Dane z API (wyższy priorytet)'
|
||||
company_result['profiles'].append(profile_result)
|
||||
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']
|
||||
# Build before/after diff
|
||||
changes = []
|
||||
for field in tracked_fields:
|
||||
new_val = enriched.get(field)
|
||||
if new_val is None:
|
||||
continue
|
||||
old_val = getattr(profile, field, None)
|
||||
# Only record actual changes
|
||||
if old_val != new_val:
|
||||
changes.append({
|
||||
'field': field,
|
||||
'label': _FIELD_LABELS.get(field, field),
|
||||
'old': _format_value(field, old_val),
|
||||
'new': _format_value(field, new_val),
|
||||
'old_raw': str(old_val) if old_val is not None else None,
|
||||
'new_raw': str(new_val) if new_val is not None else None,
|
||||
})
|
||||
|
||||
profile.last_checked_at = datetime.now()
|
||||
db.commit()
|
||||
profile_result['status'] = 'changes' if changes else 'no_changes'
|
||||
profile_result['changes'] = changes
|
||||
profile_result['enriched_data'] = {
|
||||
k: (str(v) if hasattr(v, 'strftime') else v)
|
||||
for k, v in enriched.items()
|
||||
if k in tracked_fields and v is not None
|
||||
}
|
||||
|
||||
company_results.append({
|
||||
'platform': profile.platform,
|
||||
'status': 'enriched',
|
||||
'fields': list(enriched.keys()),
|
||||
})
|
||||
if changes:
|
||||
company_result['has_changes'] = True
|
||||
_enrichment_status['pending_changes'].append({
|
||||
'profile_id': profile.id,
|
||||
'company_id': company_id,
|
||||
'company_name': company.name,
|
||||
'platform': profile.platform,
|
||||
'enriched_data': profile_result['enriched_data'],
|
||||
'changes': changes,
|
||||
})
|
||||
else:
|
||||
profile.last_checked_at = datetime.now()
|
||||
db.commit()
|
||||
company_results.append({
|
||||
'platform': profile.platform,
|
||||
'status': 'no_data',
|
||||
})
|
||||
profile_result['status'] = 'no_data'
|
||||
profile_result['reason'] = 'Scraper nie zebrał danych'
|
||||
|
||||
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],
|
||||
})
|
||||
profile_result['status'] = 'error'
|
||||
profile_result['reason'] = str(e)[:150]
|
||||
_enrichment_status['errors'] += 1
|
||||
|
||||
_enrichment_status['results'].append({
|
||||
'company_id': company_id,
|
||||
'company_name': company.name,
|
||||
'profiles': company_results,
|
||||
})
|
||||
company_result['profiles'].append(profile_result)
|
||||
|
||||
_enrichment_status['results'].append(company_result)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Enrichment error for company {company_id}: {e}")
|
||||
@ -871,20 +919,22 @@ def _run_enrichment_background(company_ids):
|
||||
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']}")
|
||||
total_changes = len(_enrichment_status['pending_changes'])
|
||||
logger.info(f"Enrichment scan completed: {_enrichment_status['completed']}/{_enrichment_status['total']}, "
|
||||
f"{total_changes} pending changes, {_enrichment_status['errors']} 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."""
|
||||
"""Start social media profile enrichment scan (collect only, no DB writes)."""
|
||||
if not is_audit_owner():
|
||||
return jsonify({'error': 'Brak uprawnień'}), 403
|
||||
|
||||
if _enrichment_status['running']:
|
||||
return jsonify({
|
||||
'error': 'Enrichment już działa',
|
||||
'error': 'Audyt już działa',
|
||||
'progress': _enrichment_status['progress'],
|
||||
'completed': _enrichment_status['completed'],
|
||||
'total': _enrichment_status['total'],
|
||||
@ -908,6 +958,8 @@ def admin_social_audit_run_enrichment():
|
||||
_enrichment_status['running'] = True
|
||||
_enrichment_status['progress'] = 0
|
||||
_enrichment_status['results'] = []
|
||||
_enrichment_status['pending_changes'] = []
|
||||
_enrichment_status['approved'] = False
|
||||
|
||||
thread = threading.Thread(
|
||||
target=_run_enrichment_background,
|
||||
@ -919,7 +971,7 @@ def admin_social_audit_run_enrichment():
|
||||
return jsonify({
|
||||
'status': 'started',
|
||||
'total': len(company_ids),
|
||||
'message': f'Rozpoczęto audyt {len(company_ids)} firm w tle.',
|
||||
'message': f'Rozpoczęto skanowanie {len(company_ids)} firm. Dane NIE zostaną zapisane bez Twojej zgody.',
|
||||
})
|
||||
|
||||
|
||||
@ -927,7 +979,8 @@ def admin_social_audit_run_enrichment():
|
||||
@login_required
|
||||
@role_required(SystemRole.OFFICE_MANAGER)
|
||||
def admin_social_audit_enrichment_status():
|
||||
"""Get current enrichment job status."""
|
||||
"""Get current enrichment job status with pending changes summary."""
|
||||
pending = _enrichment_status.get('pending_changes', [])
|
||||
return jsonify({
|
||||
'running': _enrichment_status['running'],
|
||||
'progress': _enrichment_status['progress'],
|
||||
@ -935,5 +988,141 @@ def admin_social_audit_enrichment_status():
|
||||
'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:],
|
||||
'pending_count': len(pending),
|
||||
'approved': _enrichment_status.get('approved', False),
|
||||
})
|
||||
|
||||
|
||||
@bp.route('/social-audit/enrichment-review')
|
||||
@login_required
|
||||
@role_required(SystemRole.ADMIN)
|
||||
def admin_social_audit_enrichment_review():
|
||||
"""Review page showing all collected changes before applying."""
|
||||
if not is_audit_owner():
|
||||
from flask import abort
|
||||
abort(404)
|
||||
|
||||
if _enrichment_status['running']:
|
||||
flash('Audyt wciąż trwa. Poczekaj na zakończenie.', 'warning')
|
||||
return redirect(url_for('admin.admin_social_audit'))
|
||||
|
||||
results = _enrichment_status.get('results', [])
|
||||
pending = _enrichment_status.get('pending_changes', [])
|
||||
|
||||
# Summary stats
|
||||
total_profiles_scanned = sum(len(r['profiles']) for r in results)
|
||||
profiles_with_changes = len(pending)
|
||||
profiles_skipped = sum(1 for r in results for p in r['profiles'] if p.get('status') == 'skipped')
|
||||
profiles_no_data = sum(1 for r in results for p in r['profiles'] if p.get('status') in ('no_data', 'no_changes'))
|
||||
profiles_errors = sum(1 for r in results for p in r['profiles'] if p.get('status') == 'error')
|
||||
companies_with_changes = len(set(c['company_id'] for c in pending))
|
||||
|
||||
summary = {
|
||||
'total_companies': _enrichment_status.get('total', 0),
|
||||
'total_profiles_scanned': total_profiles_scanned,
|
||||
'profiles_with_changes': profiles_with_changes,
|
||||
'profiles_skipped': profiles_skipped,
|
||||
'profiles_no_data': profiles_no_data,
|
||||
'profiles_errors': profiles_errors,
|
||||
'companies_with_changes': companies_with_changes,
|
||||
'last_run': _enrichment_status.get('last_run'),
|
||||
}
|
||||
|
||||
# Only show companies that have changes or errors
|
||||
results_to_show = [r for r in results if r.get('has_changes') or
|
||||
any(p.get('status') == 'error' for p in r.get('profiles', []))]
|
||||
|
||||
return render_template('admin/social_audit_enrichment_review.html',
|
||||
results=results_to_show,
|
||||
all_results=results,
|
||||
summary=summary,
|
||||
approved=_enrichment_status.get('approved', False),
|
||||
)
|
||||
|
||||
|
||||
@bp.route('/social-audit/enrichment-approve', methods=['POST'])
|
||||
@login_required
|
||||
@role_required(SystemRole.ADMIN)
|
||||
def admin_social_audit_enrichment_approve():
|
||||
"""Apply all pending enrichment changes to database."""
|
||||
if not is_audit_owner():
|
||||
return jsonify({'error': 'Brak uprawnień'}), 403
|
||||
|
||||
if _enrichment_status['running']:
|
||||
return jsonify({'error': 'Audyt wciąż trwa'}), 409
|
||||
|
||||
pending = _enrichment_status.get('pending_changes', [])
|
||||
if not pending:
|
||||
return jsonify({'error': 'Brak oczekujących zmian do zatwierdzenia'}), 400
|
||||
|
||||
if _enrichment_status.get('approved'):
|
||||
return jsonify({'error': 'Zmiany zostały już zatwierdzone'}), 409
|
||||
|
||||
db = SessionLocal()
|
||||
applied = 0
|
||||
errors = 0
|
||||
try:
|
||||
for change in pending:
|
||||
try:
|
||||
profile = db.query(CompanySocialMedia).filter_by(id=change['profile_id']).first()
|
||||
if not profile:
|
||||
errors += 1
|
||||
continue
|
||||
|
||||
enriched = change['enriched_data']
|
||||
for field, value in enriched.items():
|
||||
if field == 'last_post_date' and isinstance(value, str):
|
||||
try:
|
||||
from dateutil.parser import parse as parse_date
|
||||
value = parse_date(value)
|
||||
except (ImportError, ValueError):
|
||||
continue
|
||||
if field == 'engagement_rate' and isinstance(value, str):
|
||||
value = float(value)
|
||||
setattr(profile, field, value)
|
||||
|
||||
profile.last_checked_at = datetime.now()
|
||||
applied += 1
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to apply enrichment for profile {change.get('profile_id')}: {e}")
|
||||
errors += 1
|
||||
|
||||
db.commit()
|
||||
_enrichment_status['approved'] = True
|
||||
|
||||
logger.info(f"Enrichment approved: {applied} profiles updated, {errors} errors")
|
||||
flash(f'Zatwierdzone: zaktualizowano {applied} profili.', 'success')
|
||||
|
||||
return jsonify({
|
||||
'status': 'approved',
|
||||
'applied': applied,
|
||||
'errors': errors,
|
||||
'message': f'Zaktualizowano {applied} profili.',
|
||||
})
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error(f"Enrichment approval failed: {e}")
|
||||
return jsonify({'error': f'Błąd zapisu: {str(e)[:200]}'}), 500
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@bp.route('/social-audit/enrichment-discard', methods=['POST'])
|
||||
@login_required
|
||||
@role_required(SystemRole.ADMIN)
|
||||
def admin_social_audit_enrichment_discard():
|
||||
"""Discard all pending enrichment changes."""
|
||||
if not is_audit_owner():
|
||||
return jsonify({'error': 'Brak uprawnień'}), 403
|
||||
|
||||
count = len(_enrichment_status.get('pending_changes', []))
|
||||
_enrichment_status['pending_changes'] = []
|
||||
_enrichment_status['results'] = []
|
||||
_enrichment_status['approved'] = False
|
||||
|
||||
flash(f'Odrzucono {count} oczekujących zmian. Baza danych nie została zmieniona.', 'info')
|
||||
return jsonify({
|
||||
'status': 'discarded',
|
||||
'count': count,
|
||||
})
|
||||
|
||||
@ -990,7 +990,7 @@ function resetFilters() {
|
||||
|
||||
// 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;
|
||||
if (!confirm('Uruchomić skanowanie social media dla wszystkich firm?\n\nProces działa w tle i może potrwać kilka minut.\nDane NIE zostaną zapisane bez Twojej zgody — po zakończeniu zobaczysz raport ze zmianami.\nProfile z danymi z API (OAuth) nie będą nadpisywane.')) return;
|
||||
|
||||
var btn = document.getElementById('enrichBtn');
|
||||
var progress = document.getElementById('enrichProgress');
|
||||
@ -1005,7 +1005,7 @@ function startEnrichment() {
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (data.status === 'started') {
|
||||
document.getElementById('enrichText').textContent = 'Audyt: 0/' + data.total;
|
||||
document.getElementById('enrichText').textContent = 'Skanowanie: 0/' + data.total;
|
||||
pollEnrichment();
|
||||
} else {
|
||||
alert(data.error || 'Błąd uruchamiania');
|
||||
@ -1027,21 +1027,27 @@ function pollEnrichment() {
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
document.getElementById('enrichPct').textContent = data.progress + '%';
|
||||
document.getElementById('enrichText').textContent = 'Audyt: ' + data.completed + '/' + data.total;
|
||||
document.getElementById('enrichText').textContent = 'Skanowanie: ' + 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';
|
||||
// Scan complete — redirect to review page
|
||||
if (data.pending_count > 0) {
|
||||
document.getElementById('enrichText').textContent = data.pending_count + ' zmian do zatwierdzenia...';
|
||||
window.location.href = '{{ url_for("admin.admin_social_audit_enrichment_review") }}';
|
||||
} 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;
|
||||
var errInfo = data.errors > 0 ? ', ' + data.errors + ' błędów' : '';
|
||||
document.getElementById('enrichText').textContent = 'Brak nowych danych' + errInfo;
|
||||
|
||||
setTimeout(function() {
|
||||
document.getElementById('enrichProgress').style.display = 'none';
|
||||
}, 10000);
|
||||
setTimeout(function() {
|
||||
document.getElementById('enrichProgress').style.display = 'none';
|
||||
}, 8000);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@ -907,8 +907,13 @@ function pollSingleEnrichment() {
|
||||
.then(function(data) {
|
||||
if (data.running) {
|
||||
setTimeout(pollSingleEnrichment, 2000);
|
||||
} else if (data.pending_count > 0) {
|
||||
window.location.href = '{{ url_for("admin.admin_social_audit_enrichment_review") }}';
|
||||
} else {
|
||||
location.reload();
|
||||
var btn = document.getElementById('enrichSingleBtn');
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Brak nowych danych';
|
||||
setTimeout(function() { btn.textContent = 'Uruchom audyt'; }, 5000);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
381
templates/admin/social_audit_enrichment_review.html
Normal file
381
templates/admin/social_audit_enrichment_review.html
Normal file
@ -0,0 +1,381 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Raport audytu Social Media - Norda Biznes Partner{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
.review-page { max-width: 1100px; margin: 0 auto; }
|
||||
|
||||
.review-header {
|
||||
display: flex; justify-content: space-between; align-items: flex-start;
|
||||
margin-bottom: var(--spacing-xl); flex-wrap: wrap; gap: var(--spacing-md);
|
||||
}
|
||||
|
||||
.review-header h1 { font-size: var(--font-size-2xl); margin: 0; }
|
||||
|
||||
.review-actions { display: flex; gap: var(--spacing-sm); align-items: center; }
|
||||
|
||||
/* Summary cards */
|
||||
.review-summary {
|
||||
display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
gap: var(--spacing-md); margin-bottom: var(--spacing-xl);
|
||||
}
|
||||
|
||||
.review-stat {
|
||||
background: var(--surface); padding: var(--spacing-md);
|
||||
border-radius: var(--radius-lg); box-shadow: var(--shadow-sm); text-align: center;
|
||||
}
|
||||
|
||||
.review-stat-value { font-size: var(--font-size-2xl); font-weight: 700; display: block; }
|
||||
.review-stat-label { font-size: var(--font-size-sm); color: var(--text-secondary); }
|
||||
|
||||
.review-stat-value.green { color: #22c55e; }
|
||||
.review-stat-value.yellow { color: #f59e0b; }
|
||||
.review-stat-value.red { color: #ef4444; }
|
||||
.review-stat-value.blue { color: #3b82f6; }
|
||||
.review-stat-value.gray { color: #9ca3af; }
|
||||
|
||||
/* Company section */
|
||||
.company-review {
|
||||
background: var(--surface); border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-sm); margin-bottom: var(--spacing-md); overflow: hidden;
|
||||
}
|
||||
|
||||
.company-review-header {
|
||||
display: flex; align-items: center; gap: var(--spacing-md);
|
||||
padding: var(--spacing-md) var(--spacing-lg);
|
||||
background: var(--background); cursor: pointer;
|
||||
border-bottom: 1px solid var(--border-color, #e5e7eb);
|
||||
}
|
||||
|
||||
.company-review-header:hover { background: #f3f4f6; }
|
||||
|
||||
.company-review-header h3 { margin: 0; font-size: var(--font-size-base); flex: 1; }
|
||||
|
||||
.company-review-header .badge {
|
||||
padding: 2px 8px; border-radius: 10px; font-size: 11px; font-weight: 600;
|
||||
}
|
||||
|
||||
.badge.changes { background: #dbeafe; color: #1d4ed8; }
|
||||
.badge.error { background: #fee2e2; color: #991b1b; }
|
||||
.badge.skipped { background: #f3f4f6; color: #6b7280; }
|
||||
|
||||
.company-review-body { padding: var(--spacing-md) var(--spacing-lg); }
|
||||
|
||||
/* Platform diff */
|
||||
.platform-diff {
|
||||
margin-bottom: var(--spacing-md); padding: var(--spacing-md);
|
||||
background: var(--background); border-radius: var(--radius);
|
||||
border-left: 3px solid #3b82f6;
|
||||
}
|
||||
|
||||
.platform-diff.error { border-left-color: #ef4444; }
|
||||
.platform-diff.skipped { border-left-color: #9ca3af; }
|
||||
.platform-diff.no-changes { border-left-color: #22c55e; }
|
||||
|
||||
.platform-diff-header {
|
||||
display: flex; align-items: center; gap: var(--spacing-sm);
|
||||
margin-bottom: var(--spacing-sm); font-weight: 600;
|
||||
}
|
||||
|
||||
/* Changes table */
|
||||
.changes-table { width: 100%; font-size: var(--font-size-sm); border-collapse: collapse; }
|
||||
.changes-table th {
|
||||
text-align: left; padding: 4px 8px; font-weight: 500;
|
||||
color: var(--text-secondary); font-size: var(--font-size-xs);
|
||||
border-bottom: 1px solid var(--border-color, #e5e7eb);
|
||||
}
|
||||
.changes-table td { padding: 6px 8px; border-bottom: 1px solid #f3f4f6; }
|
||||
.changes-table .old-val { color: #991b1b; text-decoration: line-through; }
|
||||
.changes-table .new-val { color: #15803d; font-weight: 500; }
|
||||
.changes-table .arrow { color: var(--text-secondary); padding: 0 4px; }
|
||||
|
||||
/* Status message */
|
||||
.approval-banner {
|
||||
padding: var(--spacing-md) var(--spacing-lg);
|
||||
border-radius: var(--radius-lg); margin-bottom: var(--spacing-xl);
|
||||
display: flex; align-items: center; gap: var(--spacing-md);
|
||||
}
|
||||
|
||||
.approval-banner.pending {
|
||||
background: #fef3c7; border: 1px solid #f59e0b; color: #92400e;
|
||||
}
|
||||
|
||||
.approval-banner.approved {
|
||||
background: #dcfce7; border: 1px solid #22c55e; color: #15803d;
|
||||
}
|
||||
|
||||
.approval-banner.empty {
|
||||
background: #f3f4f6; border: 1px solid #d1d5db; color: #6b7280;
|
||||
}
|
||||
|
||||
.toggle-arrow { transition: transform 0.2s; display: inline-block; }
|
||||
.toggle-arrow.open { transform: rotate(90deg); }
|
||||
|
||||
.hidden { display: none !important; }
|
||||
|
||||
/* No changes section */
|
||||
.no-changes-section {
|
||||
margin-top: var(--spacing-xl); padding: var(--spacing-md);
|
||||
background: var(--background); border-radius: var(--radius-lg);
|
||||
}
|
||||
|
||||
.no-changes-list {
|
||||
display: flex; flex-wrap: wrap; gap: var(--spacing-xs);
|
||||
font-size: var(--font-size-xs); color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.no-changes-list span {
|
||||
background: var(--surface); padding: 2px 8px;
|
||||
border-radius: var(--radius); border: 1px solid var(--border-color, #e5e7eb);
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="review-page">
|
||||
<a href="{{ url_for('admin.admin_social_audit') }}" class="back-link" style="display: inline-flex; align-items: center; gap: 4px; color: var(--text-secondary); text-decoration: none; font-size: var(--font-size-sm); margin-bottom: var(--spacing-md);">
|
||||
<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="M15 19l-7-7 7-7"/></svg>
|
||||
Powrót do dashboardu
|
||||
</a>
|
||||
|
||||
<div class="review-header">
|
||||
<div>
|
||||
<h1>Raport audytu Social Media</h1>
|
||||
{% if summary.last_run %}
|
||||
<p style="color: var(--text-secondary); font-size: var(--font-size-sm); margin: 4px 0 0;">
|
||||
Skanowanie: {{ summary.last_run.strftime('%d.%m.%Y %H:%M') }}
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if not approved and summary.profiles_with_changes > 0 %}
|
||||
<div class="review-actions">
|
||||
<button class="btn btn-danger btn-sm" onclick="discardChanges()" id="discardBtn">
|
||||
<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="M6 18L18 6M6 6l12 12"/></svg>
|
||||
Odrzuć wszystko
|
||||
</button>
|
||||
<button class="btn btn-primary" onclick="approveChanges()" id="approveBtn">
|
||||
<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="M5 13l4 4L19 7"/></svg>
|
||||
Zatwierdź {{ summary.profiles_with_changes }} zmian
|
||||
</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Summary -->
|
||||
<div class="review-summary">
|
||||
<div class="review-stat">
|
||||
<span class="review-stat-value">{{ summary.total_companies }}</span>
|
||||
<span class="review-stat-label">Firm przeskanowanych</span>
|
||||
</div>
|
||||
<div class="review-stat">
|
||||
<span class="review-stat-value">{{ summary.total_profiles_scanned }}</span>
|
||||
<span class="review-stat-label">Profili sprawdzonych</span>
|
||||
</div>
|
||||
<div class="review-stat">
|
||||
<span class="review-stat-value blue">{{ summary.profiles_with_changes }}</span>
|
||||
<span class="review-stat-label">Z nowymi danymi</span>
|
||||
</div>
|
||||
<div class="review-stat">
|
||||
<span class="review-stat-value gray">{{ summary.profiles_skipped }}</span>
|
||||
<span class="review-stat-label">Pominiętych (API)</span>
|
||||
</div>
|
||||
<div class="review-stat">
|
||||
<span class="review-stat-value gray">{{ summary.profiles_no_data }}</span>
|
||||
<span class="review-stat-label">Bez zmian</span>
|
||||
</div>
|
||||
<div class="review-stat">
|
||||
<span class="review-stat-value red">{{ summary.profiles_errors }}</span>
|
||||
<span class="review-stat-label">Błędów</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Approval banner -->
|
||||
{% if approved %}
|
||||
<div class="approval-banner approved">
|
||||
<svg width="24" height="24" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
|
||||
<div>
|
||||
<strong>Zmiany zostały zatwierdzone i zapisane do bazy danych.</strong>
|
||||
<p style="margin: 4px 0 0; font-size: var(--font-size-sm);">Dane zostały zaktualizowane. Możesz wrócić do dashboardu lub uruchomić nowy audyt.</p>
|
||||
</div>
|
||||
</div>
|
||||
{% elif summary.profiles_with_changes > 0 %}
|
||||
<div class="approval-banner pending">
|
||||
<svg width="24" height="24" fill="none" stroke="currentColor" 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>
|
||||
<div>
|
||||
<strong>{{ summary.profiles_with_changes }} profili oczekuje na zatwierdzenie.</strong>
|
||||
<p style="margin: 4px 0 0; font-size: var(--font-size-sm);">
|
||||
Przejrzyj zmiany poniżej. Dane NIE zostały jeszcze zapisane do bazy.
|
||||
Kliknij <strong>Zatwierdź</strong> aby zapisać lub <strong>Odrzuć</strong> aby anulować.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="approval-banner empty">
|
||||
<svg width="24" height="24" fill="none" stroke="currentColor" 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>
|
||||
<div>
|
||||
<strong>Audyt nie znalazł nowych danych do zaktualizowania.</strong>
|
||||
<p style="margin: 4px 0 0; font-size: var(--font-size-sm);">Scraper nie zebrał żadnych nowych informacji. Profie mogą być zablokowane lub dane są już aktualne.</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Changes per company -->
|
||||
{% for result in results %}
|
||||
<div class="company-review">
|
||||
<div class="company-review-header" onclick="toggleCompany(this)">
|
||||
<span class="toggle-arrow">▶</span>
|
||||
<h3>{{ result.company_name }}</h3>
|
||||
{% set change_count = result.profiles|selectattr('status', 'eq', 'changes')|list|length %}
|
||||
{% set error_count = result.profiles|selectattr('status', 'eq', 'error')|list|length %}
|
||||
{% set skip_count = result.profiles|selectattr('status', 'eq', 'skipped')|list|length %}
|
||||
{% if change_count > 0 %}
|
||||
<span class="badge changes">{{ change_count }} zmian</span>
|
||||
{% endif %}
|
||||
{% if error_count > 0 %}
|
||||
<span class="badge error">{{ error_count }} błędów</span>
|
||||
{% endif %}
|
||||
{% if skip_count > 0 %}
|
||||
<span class="badge skipped">{{ skip_count }} pom.</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="company-review-body hidden">
|
||||
{% for p in result.profiles %}
|
||||
<div class="platform-diff {{ p.status if p.status in ('error', 'skipped') else ('no-changes' if p.status == 'no_changes' else '') }}">
|
||||
<div class="platform-diff-header">
|
||||
<span style="text-transform: capitalize;">{{ p.platform }}</span>
|
||||
{% if p.status == 'changes' %}
|
||||
<span style="color: #3b82f6; font-size: var(--font-size-xs); font-weight: 400;">{{ p.changes|length }} pól do aktualizacji</span>
|
||||
{% elif p.status == 'skipped' %}
|
||||
<span style="color: #9ca3af; font-size: var(--font-size-xs); font-weight: 400;">{{ p.reason }}</span>
|
||||
{% elif p.status == 'error' %}
|
||||
<span style="color: #ef4444; font-size: var(--font-size-xs); font-weight: 400;">{{ p.reason }}</span>
|
||||
{% elif p.status == 'no_data' %}
|
||||
<span style="color: #9ca3af; font-size: var(--font-size-xs); font-weight: 400;">{{ p.get('reason', 'Brak nowych danych') }}</span>
|
||||
{% elif p.status == 'no_changes' %}
|
||||
<span style="color: #22c55e; font-size: var(--font-size-xs); font-weight: 400;">Dane aktualne</span>
|
||||
{% endif %}
|
||||
{% if p.url %}
|
||||
<a href="{{ p.url }}" target="_blank" rel="noopener" style="font-size: 11px; color: var(--text-secondary); margin-left: auto;">{{ p.url|truncate(50) }}</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if p.status == 'changes' and p.changes %}
|
||||
<table class="changes-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 30%;">Pole</th>
|
||||
<th style="width: 30%;">Obecna wartość</th>
|
||||
<th style="width: 5%;"></th>
|
||||
<th style="width: 35%;">Nowa wartość</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for ch in p.changes %}
|
||||
<tr>
|
||||
<td>{{ ch.label }}</td>
|
||||
<td class="old-val">{{ ch.old }}</td>
|
||||
<td class="arrow">→</td>
|
||||
<td class="new-val">{{ ch.new }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
<!-- Companies without changes (collapsed) -->
|
||||
{% set no_change_companies = [] %}
|
||||
{% for r in all_results %}
|
||||
{% if not r.has_changes and not r.profiles|selectattr('status', 'eq', 'error')|list %}
|
||||
{% if no_change_companies.append(r) %}{% endif %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
{% if no_change_companies %}
|
||||
<div class="no-changes-section">
|
||||
<p style="font-size: var(--font-size-sm); font-weight: 500; margin: 0 0 var(--spacing-sm);">
|
||||
{{ no_change_companies|length }} firm bez zmian:
|
||||
</p>
|
||||
<div class="no-changes-list">
|
||||
{% for r in no_change_companies %}
|
||||
<span>{{ r.company_name }}</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Bottom actions (fixed for long pages) -->
|
||||
{% if not approved and summary.profiles_with_changes > 0 %}
|
||||
<div style="position: sticky; bottom: 0; background: var(--surface); padding: var(--spacing-md); border-top: 2px solid var(--border-color, #e5e7eb); margin-top: var(--spacing-xl); display: flex; justify-content: space-between; align-items: center; border-radius: var(--radius-lg) var(--radius-lg) 0 0; box-shadow: 0 -4px 12px rgba(0,0,0,0.1);">
|
||||
<span style="font-size: var(--font-size-sm); color: var(--text-secondary);">
|
||||
{{ summary.profiles_with_changes }} profili ({{ summary.companies_with_changes }} firm) oczekuje na zatwierdzenie
|
||||
</span>
|
||||
<div style="display: flex; gap: var(--spacing-sm);">
|
||||
<button class="btn btn-danger btn-sm" onclick="discardChanges()">Odrzuć</button>
|
||||
<button class="btn btn-primary" onclick="approveChanges()">Zatwierdź i zapisz</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
function toggleCompany(header) {
|
||||
var body = header.nextElementSibling;
|
||||
var arrow = header.querySelector('.toggle-arrow');
|
||||
body.classList.toggle('hidden');
|
||||
arrow.classList.toggle('open');
|
||||
}
|
||||
|
||||
function approveChanges() {
|
||||
if (!confirm('Czy na pewno chcesz zatwierdzić i zapisać zebrane dane do bazy?\n\nTa operacja zaktualizuje {{ summary.profiles_with_changes }} profili w {{ summary.companies_with_changes }} firmach.')) return;
|
||||
|
||||
var approveBtn = document.getElementById('approveBtn');
|
||||
var discardBtn = document.getElementById('discardBtn');
|
||||
if (approveBtn) { approveBtn.disabled = true; approveBtn.textContent = 'Zapisywanie...'; }
|
||||
if (discardBtn) discardBtn.disabled = true;
|
||||
|
||||
fetch('{{ url_for("admin.admin_social_audit_enrichment_approve") }}', {
|
||||
method: 'POST',
|
||||
headers: {'X-CSRFToken': '{{ csrf_token() }}'},
|
||||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (data.status === 'approved') {
|
||||
alert('Zatwierdzone! Zaktualizowano ' + data.applied + ' profili.' + (data.errors > 0 ? ' (' + data.errors + ' błędów)' : ''));
|
||||
location.reload();
|
||||
} else {
|
||||
alert('Błąd: ' + (data.error || 'Nieznany błąd'));
|
||||
if (approveBtn) { approveBtn.disabled = false; approveBtn.textContent = 'Zatwierdź'; }
|
||||
if (discardBtn) discardBtn.disabled = false;
|
||||
}
|
||||
})
|
||||
.catch(function(e) {
|
||||
alert('Błąd: ' + e.message);
|
||||
if (approveBtn) { approveBtn.disabled = false; approveBtn.textContent = 'Zatwierdź'; }
|
||||
if (discardBtn) discardBtn.disabled = false;
|
||||
});
|
||||
}
|
||||
|
||||
function discardChanges() {
|
||||
if (!confirm('Czy na pewno chcesz ODRZUCIĆ wszystkie zebrane dane?\n\nBaza danych nie zostanie zmieniona.')) return;
|
||||
|
||||
fetch('{{ url_for("admin.admin_social_audit_enrichment_discard") }}', {
|
||||
method: 'POST',
|
||||
headers: {'X-CSRFToken': '{{ csrf_token() }}'},
|
||||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (data.status === 'discarded') {
|
||||
alert('Odrzucono ' + data.count + ' zmian. Baza danych nie została zmieniona.');
|
||||
window.location.href = '{{ url_for("admin.admin_social_audit") }}';
|
||||
}
|
||||
});
|
||||
}
|
||||
{% endblock %}
|
||||
Loading…
Reference in New Issue
Block a user