From 1d39c9190a3f152b8e147ed61de684a090fd5c91 Mon Sep 17 00:00:00 2001 From: Maciej Pienczyn Date: Fri, 13 Mar 2026 12:43:33 +0100 Subject: [PATCH] feat: add review-before-save workflow to GBP batch audit Batch audit now collects changes without saving to DB. Admin must review before/after differences and approve or discard. Mirrors the existing social audit enrichment review pattern. Co-Authored-By: Claude Opus 4.6 --- blueprints/admin/routes_audits.py | 284 ++++++++++++++- templates/admin/gbp_audit_batch_review.html | 379 ++++++++++++++++++++ templates/admin/gbp_audit_dashboard.html | 23 +- 3 files changed, 678 insertions(+), 8 deletions(-) create mode 100644 templates/admin/gbp_audit_batch_review.html diff --git a/blueprints/admin/routes_audits.py b/blueprints/admin/routes_audits.py index 1928c69..b17c1a1 100644 --- a/blueprints/admin/routes_audits.py +++ b/blueprints/admin/routes_audits.py @@ -40,6 +40,8 @@ _GBP_BATCH_DEFAULT = { 'completed': 0, 'errors': 0, 'results': [], + 'pending_changes': [], + 'approved': False, } @@ -66,8 +68,52 @@ def _write_gbp_batch_state(state): logger.error(f"Failed to write GBP batch state: {e}") +def _get_old_audit(db, company_id): + """Get latest existing GBP audit for comparison.""" + from sqlalchemy import func + audit = ( + db.query(GBPAudit) + .filter_by(company_id=company_id) + .order_by(GBPAudit.audit_date.desc()) + .first() + ) + if not audit: + return None + return { + 'score': audit.completeness_score, + 'rating': float(audit.average_rating) if audit.average_rating else None, + 'review_count': audit.review_count or 0, + 'photo_count': audit.photo_count or 0, + 'has_name': audit.has_name, + 'has_address': audit.has_address, + 'has_phone': audit.has_phone, + 'has_website': audit.has_website, + 'has_hours': audit.has_hours, + 'has_categories': audit.has_categories, + 'has_photos': audit.has_photos, + 'has_description': audit.has_description, + 'has_services': audit.has_services, + 'has_reviews': audit.has_reviews, + 'audit_date': str(audit.audit_date) if audit.audit_date else None, + } + + +_GBP_FIELD_LABELS = { + 'has_name': 'Nazwa firmy', + 'has_address': 'Adres', + 'has_phone': 'Telefon', + 'has_website': 'Strona WWW', + 'has_hours': 'Godziny otwarcia', + 'has_categories': 'Kategorie', + 'has_photos': 'Zdjęcia', + 'has_description': 'Opis', + 'has_services': 'Usługi', + 'has_reviews': 'Opinie', +} + + def _run_gbp_batch_background(company_ids, fetch_google): - """Background thread: audit all companies one by one.""" + """Background thread: audit all companies, collect changes without saving.""" from gbp_audit_service import GBPAuditService, fetch_google_business_data db = SessionLocal() @@ -81,17 +127,113 @@ def _run_gbp_batch_background(company_ids, fetch_google): company = db.get(Company, company_id) company_name = company.name if company else f'ID {company_id}' + # Get old audit for comparison + old_audit = _get_old_audit(db, company_id) + if fetch_google: fetch_google_business_data(db, company_id, force_refresh=True) result = service.audit_company(company_id) - service.save_audit(result, source='automated') + # DO NOT save — collect for review + + # Build new audit data + new_audit = { + 'score': result.completeness_score, + 'rating': float(result.average_rating) if result.average_rating else None, + 'review_count': result.review_count or 0, + 'photo_count': result.photo_count or 0, + } + for field_key in _GBP_FIELD_LABELS: + field_name = field_key.replace('has_', '') + field_status = result.fields.get(field_name) + new_audit[field_key] = field_status.status in ('complete', 'partial') if field_status else False + + # Detect changes + changes = [] + if old_audit: + if old_audit['score'] != new_audit['score']: + changes.append({ + 'field': 'score', 'label': 'Kompletność', + 'old': f"{old_audit['score']}%" if old_audit['score'] is not None else 'brak', + 'new': f"{new_audit['score']}%", + }) + if old_audit['rating'] != new_audit['rating']: + changes.append({ + 'field': 'rating', 'label': 'Ocena Google', + 'old': str(old_audit['rating'] or '-'), + 'new': str(new_audit['rating'] or '-'), + }) + if old_audit['review_count'] != new_audit['review_count']: + changes.append({ + 'field': 'review_count', 'label': 'Liczba opinii', + 'old': str(old_audit['review_count']), + 'new': str(new_audit['review_count']), + }) + if old_audit['photo_count'] != new_audit['photo_count']: + changes.append({ + 'field': 'photo_count', 'label': 'Liczba zdjęć', + 'old': str(old_audit['photo_count']), + 'new': str(new_audit['photo_count']), + }) + for field_key, label in _GBP_FIELD_LABELS.items(): + old_val = old_audit.get(field_key) + new_val = new_audit.get(field_key) + if old_val != new_val: + changes.append({ + 'field': field_key, 'label': label, + 'old': 'Tak' if old_val else 'Nie', + 'new': 'Tak' if new_val else 'Nie', + }) + else: + # First audit — mark as new + changes.append({ + 'field': 'score', 'label': 'Kompletność', + 'old': 'brak audytu', + 'new': f"{new_audit['score']}%", + }) + + has_changes = len(changes) > 0 + + # Store pending audit result for later save + # Serialize the AuditResult fields we need to reconstruct it + pending_entry = { + 'company_id': company_id, + 'company_name': company_name, + 'old_score': old_audit['score'] if old_audit else None, + 'new_score': new_audit['score'], + 'changes': changes, + 'has_changes': has_changes, + # Store full result data for saving on approve + 'audit_data': { + 'completeness_score': result.completeness_score, + 'review_count': result.review_count, + 'average_rating': float(result.average_rating) if result.average_rating else None, + 'photo_count': result.photo_count, + 'logo_present': result.logo_present, + 'cover_photo_present': result.cover_photo_present, + 'google_place_id': result.google_place_id, + 'google_maps_url': result.google_maps_url, + 'has_name': new_audit['has_name'], + 'has_address': new_audit['has_address'], + 'has_phone': new_audit['has_phone'], + 'has_website': new_audit['has_website'], + 'has_hours': new_audit['has_hours'], + 'has_categories': new_audit['has_categories'], + 'has_photos': new_audit['has_photos'], + 'has_description': new_audit['has_description'], + 'has_services': new_audit['has_services'], + 'has_reviews': new_audit['has_reviews'], + }, + } + state['pending_changes'].append(pending_entry) state['results'].append({ 'company_id': company_id, 'company_name': company_name, 'score': result.completeness_score, - 'status': 'ok', + 'old_score': old_audit['score'] if old_audit else None, + 'status': 'changes' if has_changes else 'no_changes', + 'changes_count': len(changes), }) except Exception as e: logger.error(f"GBP batch audit failed for company {company_id}: {e}") @@ -744,6 +886,8 @@ def admin_gbp_audit_run_batch(): 'completed': 0, 'errors': 0, 'results': [], + 'pending_changes': [], + 'approved': False, }) thread = threading.Thread( @@ -779,6 +923,140 @@ def admin_gbp_audit_batch_status(): 'errors': state.get('errors', 0), 'results': new_results, 'results_total': len(results), + 'pending_count': len(state.get('pending_changes', [])), + }) + + +@bp.route('/gbp-audit/batch-review') +@login_required +@role_required(SystemRole.ADMIN) +def admin_gbp_audit_batch_review(): + """Review pending GBP batch audit changes before saving.""" + if not is_audit_owner(): + abort(404) + + state = _read_gbp_batch_state() + + if state.get('running'): + flash('Audyt jeszcze trwa. Poczekaj na zakończenie.', 'warning') + return redirect(url_for('admin.admin_gbp_audit')) + + pending = state.get('pending_changes', []) + results = state.get('results', []) + approved = state.get('approved', False) + + # Summary stats + with_changes = [p for p in pending if p.get('has_changes')] + without_changes = [p for p in pending if not p.get('has_changes')] + errors = [r for r in results if r.get('status') == 'error'] + + summary = { + 'total_companies': len(pending) + len(errors), + 'with_changes': len(with_changes), + 'without_changes': len(without_changes), + 'errors': len(errors), + } + + return render_template('admin/gbp_audit_batch_review.html', + pending=with_changes, + no_changes=without_changes, + errors=errors, + summary=summary, + approved=approved, + ) + + +@bp.route('/gbp-audit/batch-approve', methods=['POST']) +@login_required +@role_required(SystemRole.ADMIN) +def admin_gbp_audit_batch_approve(): + """Approve and save pending GBP audit results to database.""" + if not is_audit_owner(): + return jsonify({'error': 'Brak uprawnień'}), 403 + + state = _read_gbp_batch_state() + if state.get('running'): + return jsonify({'error': 'Audyt jeszcze trwa'}), 409 + if state.get('approved'): + return jsonify({'error': 'Wyniki już zostały zatwierdzone'}), 409 + + pending = state.get('pending_changes', []) + if not pending: + return jsonify({'error': 'Brak wyników do zatwierdzenia'}), 400 + + db = SessionLocal() + applied = 0 + errors = 0 + try: + for entry in pending: + try: + data = entry['audit_data'] + audit = GBPAudit( + company_id=entry['company_id'], + completeness_score=data['completeness_score'], + review_count=data.get('review_count', 0), + average_rating=data.get('average_rating'), + photo_count=data.get('photo_count', 0), + logo_present=data.get('logo_present', False), + cover_photo_present=data.get('cover_photo_present', False), + google_place_id=data.get('google_place_id'), + google_maps_url=data.get('google_maps_url'), + has_name=data.get('has_name', False), + has_address=data.get('has_address', False), + has_phone=data.get('has_phone', False), + has_website=data.get('has_website', False), + has_hours=data.get('has_hours', False), + has_categories=data.get('has_categories', False), + has_photos=data.get('has_photos', False), + has_description=data.get('has_description', False), + has_services=data.get('has_services', False), + has_reviews=data.get('has_reviews', False), + source='automated', + audit_date=datetime.now(), + ) + db.add(audit) + applied += 1 + except Exception as e: + logger.error(f"Failed to save GBP audit for company {entry.get('company_id')}: {e}") + errors += 1 + + db.commit() + + # Mark as approved in state + state['approved'] = True + _write_gbp_batch_state(state) + + return jsonify({ + 'status': 'approved', + 'applied': applied, + 'errors': errors, + 'message': f'Zapisano {applied} audytów do bazy danych.', + }) + except Exception as e: + db.rollback() + logger.error(f"GBP batch approve failed: {e}") + return jsonify({'error': f'Błąd zapisu: {str(e)[:100]}'}), 500 + finally: + db.close() + + +@bp.route('/gbp-audit/batch-discard', methods=['POST']) +@login_required +@role_required(SystemRole.ADMIN) +def admin_gbp_audit_batch_discard(): + """Discard pending GBP batch audit results without saving.""" + if not is_audit_owner(): + return jsonify({'error': 'Brak uprawnień'}), 403 + + state = _read_gbp_batch_state() + count = len(state.get('pending_changes', [])) + + _write_gbp_batch_state(dict(_GBP_BATCH_DEFAULT)) + + return jsonify({ + 'status': 'discarded', + 'count': count, + 'message': f'Odrzucono {count} wyników audytu.', }) diff --git a/templates/admin/gbp_audit_batch_review.html b/templates/admin/gbp_audit_batch_review.html new file mode 100644 index 0000000..1cb7bbc --- /dev/null +++ b/templates/admin/gbp_audit_batch_review.html @@ -0,0 +1,379 @@ +{% extends "base.html" %} + +{% block title %}Przegląd audytu GBP - Norda Biznes Partner{% endblock %} + +{% block extra_css %} + +{% endblock %} + +{% block content %} +
+ + + + + Powrót do panelu GBP + + +
+

Przegląd wyników audytu GBP

+ {% if not approved and summary.with_changes > 0 %} +
+ + +
+ {% endif %} +
+ + +
+
+ {{ summary.total_companies }} + Firm zbadanych +
+
+ {{ summary.with_changes }} + Ze zmianami +
+
+ {{ summary.without_changes }} + Bez zmian +
+
+ {{ summary.errors }} + Błędy +
+
+ + + {% if approved %} +
+ + Wyniki zostały zatwierdzone i zapisane do bazy danych. +
+ {% elif summary.with_changes > 0 %} +
+ +
+ {{ summary.with_changes }} {{ 'firma ma' if summary.with_changes == 1 else 'firm ma' }} zmiany do zatwierdzenia. + Przejrzyj różnice poniżej i zatwierdź lub odrzuć wyniki. +
+
+ {% else %} +
+ + Brak zmian do zatwierdzenia. Wszystkie audyty są aktualne. +
+ {% endif %} + + + {% for entry in pending %} +
+
+
+ {{ entry.company_name }} + {{ entry.changes|length }} {{ 'zmiana' if entry.changes|length == 1 else 'zmian' }} +
+
+ {% if entry.old_score is not none and entry.new_score is not none %} + {% set diff = entry.new_score - entry.old_score %} + + {{ entry.old_score }}% → {{ entry.new_score }}% + {% if diff > 0 %}(+{{ diff }}){% elif diff < 0 %}({{ diff }}){% endif %} + + {% elif entry.old_score is none %} + Nowy: {{ entry.new_score }}% + {% endif %} + +
+
+ +
+ {% endfor %} + + + {% if errors %} +

Błędy ({{ errors|length }})

+ {% for err in errors %} +
+
+
+ {{ err.company_name }} + Błąd +
+
{{ err.error }}
+
+
+ {% endfor %} + {% endif %} + + + {% if no_changes %} +
+

Bez zmian ({{ no_changes|length }})

+
+ {% for entry in no_changes %} + {{ entry.company_name }} ({{ entry.new_score }}%) + {% endfor %} +
+
+ {% endif %} + + + {% if not approved and summary.with_changes > 0 %} +
+ + +
+ {% endif %} +
+ + + +{% 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 showModal(title, message, confirmText, confirmClass, onConfirm) { + document.getElementById('modalTitle').textContent = title; + document.getElementById('modalMessage').textContent = message; + var buttons = document.getElementById('modalButtons'); + buttons.innerHTML = '' + + ''; + document.getElementById('confirmModal').classList.add('active'); +} + +function closeModal() { + document.getElementById('confirmModal').classList.remove('active'); +} + +function approveChanges() { + showModal( + 'Zatwierdź wyniki audytu', + 'Czy na pewno chcesz zapisać {{ summary.with_changes }} wyników audytu GBP do bazy danych? Tej operacji nie można cofnąć.', + 'Zatwierdź i zapisz', 'btn-primary', doApprove + ); +} + +function doApprove() { + var btns = document.querySelectorAll('#approveBtn, #approveBtn2'); + btns.forEach(function(b) { b.disabled = true; b.textContent = 'Zapisywanie...'; }); + + fetch('{{ url_for("admin.admin_gbp_audit_batch_approve") }}', { + method: 'POST', + headers: {'X-CSRFToken': '{{ csrf_token() }}'} + }) + .then(function(r) { return r.json(); }) + .then(function(data) { + if (data.status === 'approved') { + window.location.reload(); + } else { + alert(data.error || 'Błąd zatwierdzania'); + btns.forEach(function(b) { b.disabled = false; b.textContent = 'Zatwierdź i zapisz'; }); + } + }) + .catch(function(e) { + alert('Błąd połączenia: ' + e.message); + btns.forEach(function(b) { b.disabled = false; b.textContent = 'Zatwierdź i zapisz'; }); + }); +} + +function discardChanges() { + showModal( + 'Odrzuć wyniki', + 'Czy na pewno chcesz odrzucić wszystkie wyniki audytu? Żadne dane nie zostaną zapisane.', + 'Odrzuć', 'btn-outline', doDiscard + ); +} + +function doDiscard() { + fetch('{{ url_for("admin.admin_gbp_audit_batch_discard") }}', { + method: 'POST', + headers: {'X-CSRFToken': '{{ csrf_token() }}'} + }) + .then(function(r) { return r.json(); }) + .then(function(data) { + if (data.status === 'discarded') { + window.location.href = '{{ url_for("admin.admin_gbp_audit") }}'; + } else { + alert(data.error || 'Błąd odrzucania'); + } + }) + .catch(function(e) { alert('Błąd połączenia: ' + e.message); }); +} +{% endblock %} diff --git a/templates/admin/gbp_audit_dashboard.html b/templates/admin/gbp_audit_dashboard.html index b3a446e..ee23a9d 100644 --- a/templates/admin/gbp_audit_dashboard.html +++ b/templates/admin/gbp_audit_dashboard.html @@ -892,11 +892,16 @@ function pollGbpBatch() { var line = document.createElement('div'); line.style.padding = '3px 0'; line.style.borderBottom = '1px solid #f3f4f6'; - if (r.status === 'ok') { + if (r.status === 'changes' || r.status === 'no_changes' || r.status === 'ok') { var scoreColor = r.score >= 90 ? '#166534' : (r.score >= 70 ? '#92400e' : '#991b1b'); var scoreBg = r.score >= 90 ? '#dcfce7' : (r.score >= 70 ? '#fef3c7' : '#fee2e2'); + var changeInfo = ''; + if (r.status === 'changes') { + var diff = r.old_score !== null ? (r.score - r.old_score) : null; + changeInfo = diff !== null ? (' (' + (diff > 0 ? '+' : '') + diff + ')') : ' nowy'; + } line.innerHTML = ' ' + r.company_name + - ' ' + r.score + '%'; + ' ' + r.score + '%' + changeInfo; } else { line.innerHTML = ' ' + r.company_name + ' ' + (r.error || 'blad') + ''; @@ -912,9 +917,17 @@ function pollGbpBatch() { // Completed document.getElementById('gbpBatchSpinner').style.animation = 'none'; document.getElementById('gbpBatchSpinner').style.borderColor = '#22c55e'; - document.getElementById('gbpBatchTitle').textContent = 'Audyt zakonczony — ' + data.completed + ' firm sprawdzonych'; - document.getElementById('gbpBatchTitle').style.color = '#166534'; - document.getElementById('gbpBatchSubtitle').textContent = data.errors > 0 ? (data.errors + ' bledow') : 'Wszystkie firmy zbadane pomyslnie'; + + var pendingCount = data.pending_count || 0; + if (pendingCount > 0) { + document.getElementById('gbpBatchTitle').innerHTML = 'Audyt zakończony — Przejrzyj ' + pendingCount + ' wyników przed zapisem'; + document.getElementById('gbpBatchTitle').style.color = '#1e40af'; + document.getElementById('gbpBatchSubtitle').textContent = 'Dane NIE zostały jeszcze zapisane — wymagają Twojej akceptacji'; + } else { + document.getElementById('gbpBatchTitle').textContent = 'Audyt zakończony — brak zmian do zatwierdzenia'; + document.getElementById('gbpBatchTitle').style.color = '#166534'; + document.getElementById('gbpBatchSubtitle').textContent = 'Wszystkie firmy zbadane pomyślnie'; + } var btn = document.getElementById('gbpBatchBtn'); btn.disabled = false;