feat(social-audit): Add ability to run Social Media audit

- Add "Uruchom audyt" button to social_audit.html
- Create POST /api/social/audit endpoint to verify profile URLs
- Add loading overlay and modal for audit progress/results
- Audit verifies each social media URL and updates check_status

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Maciej Pienczyn 2026-01-09 05:07:58 +01:00
parent 505800381e
commit cadf91b481
2 changed files with 386 additions and 0 deletions

143
app.py
View File

@ -4159,6 +4159,149 @@ def social_audit_dashboard(slug):
db.close()
@app.route('/api/social/audit', methods=['POST'])
@login_required
@limiter.limit("10 per hour")
def api_social_audit_trigger():
"""
API: Trigger Social Media audit for a company.
This endpoint verifies social media profile URLs and updates their status.
It checks if URLs are valid and accessible.
Request JSON body:
- company_id: Company ID (integer) OR
- slug: Company slug (string)
Returns:
- Success: Updated social media audit results
- Error: Error message with status code
Rate limited to 10 requests per hour per user.
"""
import requests as http_requests
# Admin or company owner check
data = request.get_json()
if not data:
return jsonify({
'success': False,
'error': 'Brak danych w żądaniu. Podaj company_id lub slug.'
}), 400
company_id = data.get('company_id')
slug = data.get('slug')
if not company_id and not slug:
return jsonify({
'success': False,
'error': 'Podaj company_id lub slug firmy do audytu.'
}), 400
db = SessionLocal()
try:
# Find company by ID or slug
if company_id:
company = db.query(Company).filter_by(id=company_id, status='active').first()
else:
company = db.query(Company).filter_by(slug=slug, status='active').first()
if not company:
return jsonify({
'success': False,
'error': 'Firma nie znaleziona lub nieaktywna.'
}), 404
# Access control - admin can audit all, users only their company
if not current_user.is_admin:
if current_user.company_id != company.id:
return jsonify({
'success': False,
'error': 'Brak uprawnień do audytu social media tej firmy.'
}), 403
logger.info(f"Social Media audit triggered by {current_user.email} for company: {company.name} (ID: {company.id})")
# Get existing social media profiles
profiles = db.query(CompanySocialMedia).filter(
CompanySocialMedia.company_id == company.id
).all()
verified_count = 0
errors = []
# Verify each profile URL
for profile in profiles:
try:
response = http_requests.head(
profile.url,
timeout=10,
allow_redirects=True,
headers={'User-Agent': 'Mozilla/5.0 (compatible; NordaBizBot/1.0)'}
)
if response.status_code == 200:
profile.is_valid = True
profile.check_status = 'ok'
verified_count += 1
elif response.status_code in [301, 302, 303, 307, 308]:
profile.is_valid = True
profile.check_status = 'redirect'
verified_count += 1
elif response.status_code == 404:
profile.is_valid = False
profile.check_status = '404'
else:
profile.is_valid = True # Assume valid if we get any response
profile.check_status = f'http_{response.status_code}'
verified_count += 1
profile.last_checked_at = datetime.now()
except http_requests.exceptions.Timeout:
profile.check_status = 'timeout'
profile.last_checked_at = datetime.now()
errors.append(f'{profile.platform}: timeout')
except http_requests.exceptions.ConnectionError:
profile.check_status = 'connection_error'
profile.last_checked_at = datetime.now()
errors.append(f'{profile.platform}: connection error')
except Exception as e:
profile.check_status = 'error'
profile.last_checked_at = datetime.now()
errors.append(f'{profile.platform}: {str(e)[:50]}')
db.commit()
# Calculate score
all_platforms = ['facebook', 'instagram', 'linkedin', 'youtube', 'twitter', 'tiktok']
profiles_dict = {p.platform: p for p in profiles}
platforms_with_profiles = len([p for p in all_platforms if p in profiles_dict])
score = int((platforms_with_profiles / len(all_platforms)) * 100)
return jsonify({
'success': True,
'message': f'Audyt Social Media zakończony. Zweryfikowano {verified_count} z {len(profiles)} profili.',
'company_id': company.id,
'company_name': company.name,
'profiles_count': len(profiles),
'verified_count': verified_count,
'score': score,
'errors': errors if errors else None
}), 200
except Exception as e:
logger.error(f"Social Media audit error for company {slug or company_id}: {e}")
db.rollback()
return jsonify({
'success': False,
'error': f'Błąd podczas audytu: {str(e)}'
}), 500
finally:
db.close()
# ============================================================
# GBP AUDIT USER-FACING DASHBOARD
# ============================================================

View File

@ -423,6 +423,142 @@
.recommendation-text strong {
color: var(--text-primary);
}
/* Loading Overlay */
.loading-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
opacity: 0;
visibility: hidden;
transition: opacity 0.3s ease, visibility 0.3s ease;
}
.loading-overlay.active {
opacity: 1;
visibility: visible;
}
.loading-content {
background: var(--surface);
padding: var(--spacing-xl);
border-radius: var(--radius-lg);
text-align: center;
max-width: 400px;
width: 90%;
}
.loading-spinner {
width: 48px;
height: 48px;
border: 4px solid #e2e8f0;
border-top-color: #a855f7;
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 0 auto var(--spacing-md);
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.loading-title {
font-size: var(--font-size-lg);
font-weight: 600;
color: var(--text-primary);
margin-bottom: var(--spacing-sm);
}
.loading-description {
color: var(--text-secondary);
font-size: var(--font-size-sm);
}
/* Modal */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 10000;
opacity: 0;
visibility: hidden;
transition: opacity 0.3s ease, visibility 0.3s ease;
}
.modal-overlay.active {
opacity: 1;
visibility: visible;
}
.modal-content {
background: var(--surface);
padding: var(--spacing-xl);
border-radius: var(--radius-lg);
max-width: 400px;
width: 90%;
text-align: center;
}
.modal-icon {
width: 48px;
height: 48px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto var(--spacing-md);
}
.modal-icon.success {
background: rgba(34, 197, 94, 0.1);
color: var(--success);
}
.modal-icon.error {
background: rgba(239, 68, 68, 0.1);
color: var(--error);
}
.modal-title {
font-size: var(--font-size-lg);
font-weight: 600;
color: var(--text-primary);
margin-bottom: var(--spacing-sm);
}
.modal-description {
color: var(--text-secondary);
font-size: var(--font-size-sm);
margin-bottom: var(--spacing-lg);
}
.modal-btn {
background: #a855f7;
color: white;
border: none;
padding: var(--spacing-sm) var(--spacing-lg);
border-radius: var(--radius);
font-weight: 500;
cursor: pointer;
transition: background 0.2s ease;
}
.modal-btn:hover {
background: #9333ea;
}
</style>
{% endblock %}
@ -454,6 +590,14 @@
</svg>
Profil firmy
</a>
{% if can_audit %}
<button class="btn btn-primary btn-sm" onclick="runAudit()" id="runAuditBtn">
<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>
{% endif %}
</div>
</div>
@ -665,4 +809,103 @@
</div>
{% endif %}
<!-- Loading Overlay -->
<div class="loading-overlay" id="loadingOverlay">
<div class="loading-content">
<div class="loading-spinner"></div>
<div class="loading-title">Trwa audyt Social Media...</div>
<div class="loading-description">Weryfikujemy profile na platformach spolecznosciowych. Moze to potrwac kilka sekund.</div>
</div>
</div>
<!-- Modal -->
<div class="modal-overlay" id="modalOverlay">
<div class="modal-content">
<div class="modal-icon" id="modalIcon">
<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="M5 13l4 4L19 7"/>
</svg>
</div>
<div class="modal-title" id="modalTitle">Sukces</div>
<div class="modal-description" id="modalDescription">Operacja zakonczona pomyslnie.</div>
<button class="modal-btn" onclick="closeModal()">OK</button>
</div>
</div>
{% endblock %}
{% block extra_js %}
const csrfToken = '{{ csrf_token() }}';
const companySlug = '{{ company.slug }}';
function showLoading() {
document.getElementById('loadingOverlay').classList.add('active');
}
function hideLoading() {
document.getElementById('loadingOverlay').classList.remove('active');
}
function showModal(title, description, isSuccess) {
const modal = document.getElementById('modalOverlay');
const icon = document.getElementById('modalIcon');
const titleEl = document.getElementById('modalTitle');
const descEl = document.getElementById('modalDescription');
titleEl.textContent = title;
descEl.textContent = description;
icon.className = 'modal-icon ' + (isSuccess ? 'success' : 'error');
icon.innerHTML = isSuccess
? '<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="M5 13l4 4L19 7"/></svg>'
: '<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="M6 18L18 6M6 6l12 12"/></svg>';
modal.classList.add('active');
}
function closeModal() {
document.getElementById('modalOverlay').classList.remove('active');
}
async function runAudit() {
const btn = document.getElementById('runAuditBtn');
if (btn) {
btn.disabled = true;
}
showLoading();
try {
const response = await fetch('/api/social/audit', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
},
body: JSON.stringify({ slug: companySlug })
});
const data = await response.json();
hideLoading();
if (response.ok && data.success) {
showModal('Audyt zakonczony', 'Audyt Social Media zostal zakonczony pomyslnie. Strona zostanie odswiezona.', true);
setTimeout(() => location.reload(), 1500);
} else {
showModal('Blad', data.error || 'Wystapil nieznany blad podczas audytu.', false);
if (btn) btn.disabled = false;
}
} catch (error) {
hideLoading();
showModal('Blad polaczenia', 'Nie udalo sie polaczyc z serwerem: ' + error.message, false);
if (btn) btn.disabled = false;
}
}
// Close modal on overlay click
document.getElementById('modalOverlay').addEventListener('click', function(e) {
if (e.target === this) {
closeModal();
}
});
{% endblock %}