feat: add batch GBP audit button to admin dashboard
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

Run audit for all active companies with background thread, live progress
panel, and optional Google Places API data refresh. Pattern mirrors
existing social audit batch implementation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Maciej Pienczyn 2026-03-13 12:04:22 +01:00
parent 700382b55d
commit ef125baf57
2 changed files with 339 additions and 1 deletions

View File

@ -5,10 +5,15 @@ Admin Audit Routes
SEO and GBP audit dashboards for admin panel.
"""
import fcntl
import json
import logging
import os
import tempfile
import threading
from datetime import datetime
from flask import abort, render_template, request, redirect, url_for, flash
from flask import abort, render_template, request, redirect, url_for, flash, jsonify
from flask_login import login_required, current_user
from . import bp
@ -22,6 +27,98 @@ from utils.decorators import role_required, is_audit_owner
logger = logging.getLogger(__name__)
# ============================================================
# GBP BATCH AUDIT STATE (shared file for multi-worker safety)
# ============================================================
_GBP_BATCH_STATE_FILE = os.path.join(tempfile.gettempdir(), 'nordabiz_gbp_batch_state.json')
_GBP_BATCH_DEFAULT = {
'running': False,
'progress': 0,
'total': 0,
'completed': 0,
'errors': 0,
'results': [],
}
def _read_gbp_batch_state():
try:
with open(_GBP_BATCH_STATE_FILE, 'r') as f:
fcntl.flock(f, fcntl.LOCK_SH)
data = json.load(f)
fcntl.flock(f, fcntl.LOCK_UN)
return data
except (FileNotFoundError, json.JSONDecodeError, IOError):
return dict(_GBP_BATCH_DEFAULT)
def _write_gbp_batch_state(state):
try:
tmp_path = _GBP_BATCH_STATE_FILE + '.tmp'
with open(tmp_path, 'w') as f:
fcntl.flock(f, fcntl.LOCK_EX)
json.dump(state, f, default=str)
fcntl.flock(f, fcntl.LOCK_UN)
os.replace(tmp_path, _GBP_BATCH_STATE_FILE)
except IOError as e:
logger.error(f"Failed to write GBP batch state: {e}")
def _run_gbp_batch_background(company_ids, fetch_google):
"""Background thread: audit all companies one by one."""
from gbp_audit_service import GBPAuditService, fetch_google_business_data
db = SessionLocal()
try:
service = GBPAuditService(db)
state = _read_gbp_batch_state()
for i, company_id in enumerate(company_ids):
company_name = '?'
try:
company = db.get(Company, company_id)
company_name = company.name if company else f'ID {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')
state['results'].append({
'company_id': company_id,
'company_name': company_name,
'score': result.completeness_score,
'status': 'ok',
})
except Exception as e:
logger.error(f"GBP batch audit failed for company {company_id}: {e}")
state['errors'] += 1
state['results'].append({
'company_id': company_id,
'company_name': company_name,
'score': None,
'status': 'error',
'error': str(e)[:100],
})
state['completed'] = i + 1
state['progress'] = round((i + 1) / state['total'] * 100)
_write_gbp_batch_state(state)
state['running'] = False
_write_gbp_batch_state(state)
except Exception as e:
logger.error(f"GBP batch audit thread crashed: {e}")
state = _read_gbp_batch_state()
state['running'] = False
_write_gbp_batch_state(state)
finally:
db.close()
# ============================================================
# SEO ADMIN DASHBOARD
# ============================================================
@ -612,6 +709,79 @@ def admin_gbp_audit():
db.close()
@bp.route('/gbp-audit/run-batch', methods=['POST'])
@login_required
@role_required(SystemRole.ADMIN)
def admin_gbp_audit_run_batch():
"""Start batch GBP audit for all active companies."""
if not is_audit_owner():
return jsonify({'error': 'Brak uprawnień'}), 403
state = _read_gbp_batch_state()
if state.get('running'):
return jsonify({
'error': 'Audyt już działa',
'progress': state.get('progress', 0),
'completed': state.get('completed', 0),
'total': state.get('total', 0),
}), 409
fetch_google = request.form.get('fetch_google', '0') == '1'
db = SessionLocal()
try:
company_ids = [c.id for c in db.query(Company.id).filter(Company.status == 'active').all()]
finally:
db.close()
if not company_ids:
return jsonify({'error': 'Brak aktywnych firm'}), 400
_write_gbp_batch_state({
'running': True,
'progress': 0,
'total': len(company_ids),
'completed': 0,
'errors': 0,
'results': [],
})
thread = threading.Thread(
target=_run_gbp_batch_background,
args=(company_ids, fetch_google),
daemon=True
)
thread.start()
return jsonify({
'status': 'started',
'total': len(company_ids),
'message': f'Rozpoczęto audyt GBP dla {len(company_ids)} firm.',
})
@bp.route('/gbp-audit/batch-status')
@login_required
@role_required(SystemRole.OFFICE_MANAGER)
def admin_gbp_audit_batch_status():
"""Get current batch GBP audit status with live results feed."""
state = _read_gbp_batch_state()
results = state.get('results', [])
since = request.args.get('since', 0, type=int)
new_results = results[since:]
return jsonify({
'running': state.get('running', False),
'progress': state.get('progress', 0),
'completed': state.get('completed', 0),
'total': state.get('total', 0),
'errors': state.get('errors', 0),
'results': new_results,
'results_total': len(results),
})
# ============================================================
# DIGITAL MATURITY DASHBOARD
# ============================================================

View File

@ -432,6 +432,8 @@
grid-template-columns: 1fr;
}
}
@keyframes spin { to { transform: rotate(360deg); } }
</style>
{% endblock %}
@ -448,6 +450,12 @@
</div>
</div>
<div class="header-actions">
<button id="gbpBatchBtn" class="btn btn-primary btn-sm" onclick="startGbpBatch()" title="Uruchom audyt GBP 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>
<a href="{{ url_for('api.api_gbp_audit_health') }}" class="btn btn-outline btn-sm" target="_blank">
<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"/>
@ -457,6 +465,49 @@
</div>
</div>
<!-- Confirmation Modal -->
<div id="gbpBatchConfirm" style="display:none; position:fixed; top:0; left:0; right:0; bottom:0; background:rgba(0,0,0,0.5); z-index:9999; align-items:center; justify-content:center;">
<div style="background:white; border-radius:var(--radius-lg); padding:var(--spacing-xl); max-width:480px; width:90%; box-shadow:var(--shadow-lg);">
<h3 style="margin-bottom:var(--spacing-md);">Uruchom audyt GBP</h3>
<p style="color:var(--text-secondary); margin-bottom:var(--spacing-md);">
Audyt sprawdzi kompletnosc profili Google Business Profile dla wszystkich aktywnych firm.
Wyniki zostana zapisane automatycznie.
</p>
<div style="margin-bottom:var(--spacing-lg);">
<label style="display:flex; align-items:center; gap:var(--spacing-sm); cursor:pointer;">
<input type="checkbox" id="gbpFetchGoogle" checked>
<span style="font-size:var(--font-size-sm);">Pobierz aktualne dane z Google Places API (wolniejsze, ale dokladniejsze)</span>
</label>
</div>
<div style="display:flex; gap:var(--spacing-sm); justify-content:flex-end;">
<button class="btn btn-outline btn-sm" onclick="document.getElementById('gbpBatchConfirm').style.display='none'">Anuluj</button>
<button class="btn btn-primary btn-sm" onclick="document.getElementById('gbpBatchConfirm').style.display='none'; doStartGbpBatch();">
<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>
</div>
</div>
</div>
<!-- Live batch audit panel -->
<div id="gbpBatchPanel" style="display:none; margin-bottom:var(--spacing-xl); background:var(--surface); border-radius:var(--radius-lg); box-shadow:var(--shadow-sm); overflow:hidden;">
<div style="padding:var(--spacing-md) var(--spacing-lg); background:#eff6ff; border-bottom:1px solid #bfdbfe; display:flex; align-items:center; gap:var(--spacing-md);">
<div id="gbpBatchSpinner" style="width:20px; height:20px; border:3px solid #bfdbfe; border-top-color:#2563eb; border-radius:50%; animation:spin 0.8s linear infinite;"></div>
<div style="flex:1;">
<div style="font-weight:600; color:#1e40af;" id="gbpBatchTitle">Audyt GBP w toku...</div>
<div style="font-size:var(--font-size-xs); color:#3b82f6;" id="gbpBatchSubtitle">Sprawdzanie profili Google Business Profile</div>
</div>
<div style="text-align:right;">
<div style="font-size:var(--font-size-2xl); font-weight:700; color:#1e40af;" id="gbpBatchCounter">0 / 0</div>
<div style="font-size:var(--font-size-xs); color:#3b82f6;" id="gbpBatchErrors"></div>
</div>
</div>
<div style="height:6px; background:#e0e7ff;">
<div id="gbpBatchProgressBar" style="height:100%; background:#2563eb; transition:width 0.3s; width:0%;"></div>
</div>
<div id="gbpBatchFeed" style="max-height:300px; overflow-y:auto; padding:var(--spacing-sm) var(--spacing-lg); font-size:var(--font-size-sm); font-family:monospace;"></div>
</div>
<!-- Summary Stats -->
<div class="stats-grid">
<div class="stat-card">
@ -775,4 +826,121 @@ function resetFilters() {
document.getElementById('filterSearch').value = '';
applyFilters();
}
// ============================================================
// GBP Batch Audit
// ============================================================
var _gbpBatchSince = 0;
function startGbpBatch() {
document.getElementById('gbpBatchConfirm').style.display = 'flex';
}
function doStartGbpBatch() {
var btn = document.getElementById('gbpBatchBtn');
btn.disabled = true;
btn.textContent = 'Uruchamianie...';
_gbpBatchSince = 0;
var fetchGoogle = document.getElementById('gbpFetchGoogle').checked ? '1' : '0';
fetch('{{ url_for("admin.admin_gbp_audit_run_batch") }}', {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded', 'X-CSRFToken': '{{ csrf_token() }}'},
body: 'fetch_google=' + fetchGoogle
})
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.status === 'started') {
document.getElementById('gbpBatchPanel').style.display = 'block';
document.getElementById('gbpBatchCounter').textContent = '0 / ' + data.total;
document.getElementById('gbpBatchFeed').innerHTML = '';
pollGbpBatch();
} else {
document.getElementById('gbpBatchPanel').style.display = 'block';
document.getElementById('gbpBatchTitle').textContent = data.error || 'Blad uruchamiania';
document.getElementById('gbpBatchTitle').style.color = '#dc2626';
document.getElementById('gbpBatchSpinner').style.display = 'none';
btn.disabled = false;
btn.textContent = 'Uruchom audyt';
}
})
.catch(function(e) {
document.getElementById('gbpBatchPanel').style.display = 'block';
document.getElementById('gbpBatchTitle').textContent = 'Blad polaczenia: ' + e.message;
document.getElementById('gbpBatchTitle').style.color = '#dc2626';
document.getElementById('gbpBatchSpinner').style.display = 'none';
btn.disabled = false;
btn.textContent = 'Uruchom audyt';
});
}
function pollGbpBatch() {
fetch('{{ url_for("admin.admin_gbp_audit_batch_status") }}?since=' + _gbpBatchSince)
.then(function(r) { return r.json(); })
.then(function(data) {
document.getElementById('gbpBatchCounter').textContent = data.completed + ' / ' + data.total;
document.getElementById('gbpBatchProgressBar').style.width = data.progress + '%';
if (data.errors > 0) {
document.getElementById('gbpBatchErrors').textContent = data.errors + ' bledow';
}
// Append new feed entries
var feed = document.getElementById('gbpBatchFeed');
data.results.forEach(function(r) {
var line = document.createElement('div');
line.style.padding = '3px 0';
line.style.borderBottom = '1px solid #f3f4f6';
if (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');
line.innerHTML = '<span style="color:#22c55e;">&#10003;</span> ' + r.company_name +
' <span style="background:' + scoreBg + '; color:' + scoreColor + '; padding:1px 6px; border-radius:3px; font-size:11px;">' + r.score + '%</span>';
} else {
line.innerHTML = '<span style="color:#dc2626;">&#10007;</span> ' + r.company_name +
' <span style="color:#dc2626; font-size:11px;">' + (r.error || 'blad') + '</span>';
}
feed.appendChild(line);
});
feed.scrollTop = feed.scrollHeight;
_gbpBatchSince += data.results.length;
if (data.running) {
setTimeout(pollGbpBatch, 2000);
} else {
// 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 btn = document.getElementById('gbpBatchBtn');
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';
}
})
.catch(function() {
setTimeout(pollGbpBatch, 5000);
});
}
// Check if batch is already running on page load
(function() {
fetch('{{ url_for("admin.admin_gbp_audit_batch_status") }}?since=0')
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.running) {
document.getElementById('gbpBatchPanel').style.display = 'block';
document.getElementById('gbpBatchCounter').textContent = data.completed + ' / ' + data.total;
document.getElementById('gbpBatchProgressBar').style.width = data.progress + '%';
document.getElementById('gbpBatchBtn').disabled = true;
document.getElementById('gbpBatchBtn').textContent = 'Audyt w toku...';
_gbpBatchSince = data.results_total;
pollGbpBatch();
}
})
.catch(function() {});
})();
{% endblock %}