feat: add alert action buttons and companies needing attention
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
Alert improvements: - "Send welcome email" button on never-logged-in alerts (sends activation email with 72h reset token) - "Reset password" button on reset-no-effect and repeat-resets alerts - Buttons show status: sending → sent/error, prevent double-clicks - New POST /admin/analytics/send-welcome/<user_id> endpoint Companies needing attention: - New section on Overview tab listing active companies with incomplete profiles (missing description, contact, website, address, logo) - Sorted by number of issues, shows quality badge and edit link - Checks logo file existence on disk for webp/svg/png/jpg Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
618bd9b8d3
commit
53491db06a
@ -1455,6 +1455,41 @@ def _tab_overview(db, start_date, days):
|
||||
} for r in company_views_raw]
|
||||
max_company = company_popularity[0]['views'] if company_popularity else 1
|
||||
|
||||
# Companies needing attention (active, with incomplete profiles)
|
||||
import os
|
||||
all_companies = db.query(Company).filter(Company.status == 'active').all()
|
||||
companies_attention = []
|
||||
for co in all_companies:
|
||||
issues = []
|
||||
if not co.description_short and not co.description_full:
|
||||
issues.append('Brak opisu')
|
||||
if not co.phone and not co.email:
|
||||
issues.append('Brak kontaktu')
|
||||
if not co.website:
|
||||
issues.append('Brak strony WWW')
|
||||
if not co.address_city:
|
||||
issues.append('Brak adresu')
|
||||
# Check logo file
|
||||
logo_exists = False
|
||||
for ext in ('webp', 'svg', 'png', 'jpg'):
|
||||
logo_path = os.path.join('static', 'img', 'companies', f'{co.slug}.{ext}')
|
||||
if os.path.exists(logo_path):
|
||||
logo_exists = True
|
||||
break
|
||||
if not logo_exists:
|
||||
issues.append('Brak logo')
|
||||
|
||||
if issues:
|
||||
companies_attention.append({
|
||||
'id': co.id,
|
||||
'name': co.name,
|
||||
'slug': co.slug,
|
||||
'issues': issues,
|
||||
'issue_count': len(issues),
|
||||
'quality': co.data_quality or 'basic',
|
||||
})
|
||||
companies_attention.sort(key=lambda x: x['issue_count'], reverse=True)
|
||||
|
||||
return {
|
||||
'filter_type': filter_type,
|
||||
'kpi': kpi,
|
||||
@ -1476,6 +1511,8 @@ def _tab_overview(db, start_date, days):
|
||||
'referrer_sources': [{'domain': d, 'count': c, 'bar_pct': int(c / max_ref * 100)} for d, c in referrer_sources],
|
||||
'company_popularity': company_popularity,
|
||||
'max_company_views': max_company,
|
||||
'companies_attention': companies_attention[:20],
|
||||
'companies_attention_total': len(companies_attention),
|
||||
}
|
||||
|
||||
|
||||
@ -2228,6 +2265,70 @@ def user_insights_send_reset(user_id):
|
||||
db.close()
|
||||
|
||||
|
||||
@bp.route('/analytics/send-welcome/<int:user_id>', methods=['POST'])
|
||||
@login_required
|
||||
@role_required(SystemRole.OFFICE_MANAGER)
|
||||
def user_insights_send_welcome(user_id):
|
||||
"""Send welcome activation email to user who never logged in."""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
user = db.query(User).get(user_id)
|
||||
if not user or not user.is_active:
|
||||
return jsonify({'error': 'Użytkownik nie znaleziony lub nieaktywny'}), 404
|
||||
|
||||
# Generate activation token (72h validity)
|
||||
token = secrets.token_urlsafe(32)
|
||||
user.reset_token = token
|
||||
user.reset_token_expires = datetime.now() + timedelta(hours=72)
|
||||
db.commit()
|
||||
|
||||
base_url = os.getenv('APP_URL', 'https://nordabiznes.pl')
|
||||
reset_url = f"{base_url}/reset-password/{token}"
|
||||
|
||||
import email_service
|
||||
if not email_service.is_configured():
|
||||
return jsonify({'error': 'Email service nie skonfigurowany'}), 500
|
||||
|
||||
success = email_service.send_welcome_activation_email(
|
||||
email=user.email, name=user.name or user.email, reset_url=reset_url
|
||||
)
|
||||
if not success:
|
||||
return jsonify({'error': 'Błąd wysyłki emaila'}), 500
|
||||
|
||||
email_log = EmailLog(
|
||||
recipient_email=user.email,
|
||||
recipient_name=user.name,
|
||||
email_type='welcome',
|
||||
subject='Zaproszenie do portalu Norda Biznes Partner',
|
||||
status='sent',
|
||||
user_id=user.id,
|
||||
sender_email=current_user.email,
|
||||
)
|
||||
db.add(email_log)
|
||||
|
||||
audit = AuditLog(
|
||||
user_id=current_user.id,
|
||||
user_email=current_user.email,
|
||||
action='user.welcome_email_sent',
|
||||
entity_type='user',
|
||||
entity_id=user.id,
|
||||
entity_name=user.email,
|
||||
ip_address=request.remote_addr,
|
||||
request_path=request.path,
|
||||
)
|
||||
db.add(audit)
|
||||
db.commit()
|
||||
|
||||
logger.info(f"Admin {current_user.email} sent welcome email to {user.email} (user {user.id})")
|
||||
return jsonify({'success': True, 'message': f'Email powitalny wysłany do {user.email}'})
|
||||
except Exception as e:
|
||||
logger.error(f"Send welcome error for user {user_id}: {e}")
|
||||
db.rollback()
|
||||
return jsonify({'error': 'Błąd wysyłki'}), 500
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
# ============================================================
|
||||
# OLD URL REDIRECTS
|
||||
# ============================================================
|
||||
|
||||
@ -98,6 +98,15 @@
|
||||
.two-columns { display: grid; grid-template-columns: 1fr 1fr; gap: var(--spacing-xl); }
|
||||
@media (max-width: 1024px) { .two-columns { grid-template-columns: 1fr; } }
|
||||
|
||||
/* Alert action buttons */
|
||||
.btn-alert-action { padding: 4px 10px; font-size: var(--font-size-xs); border: 1px solid var(--primary); color: var(--primary); background: white; border-radius: var(--radius-sm); cursor: pointer; white-space: nowrap; transition: var(--transition); }
|
||||
.btn-alert-action:hover { background: var(--primary); color: white; }
|
||||
.btn-alert-action:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.btn-alert-action.sent { border-color: var(--success); color: var(--success); }
|
||||
|
||||
/* Issue tags */
|
||||
.issue-tag { display: inline-block; padding: 2px 8px; font-size: var(--font-size-xs); border-radius: var(--radius-sm); background: #fef3c7; color: #92400e; }
|
||||
|
||||
/* Path transitions */
|
||||
.transition-row { display: flex; align-items: center; gap: var(--spacing-sm); padding: var(--spacing-sm) 0; border-bottom: 1px solid var(--border); font-size: var(--font-size-sm); }
|
||||
.transition-arrow { color: var(--text-muted); }
|
||||
@ -239,8 +248,13 @@
|
||||
<div class="alert-message">{{ alert.user.name or alert.user.email }}: {{ alert.message }}</div>
|
||||
<div class="alert-detail">{{ alert.detail }}</div>
|
||||
</div>
|
||||
<div class="alert-action">
|
||||
<a href="{{ url_for('admin.user_insights_profile', user_id=alert.user.id, ref_tab=tab, ref_period=period) }}">Szczegóły →</a>
|
||||
<div class="alert-action" style="display: flex; gap: 8px; align-items: center; flex-wrap: wrap;">
|
||||
{% if alert.type == 'never_logged_in' %}
|
||||
<button class="btn-alert-action" onclick="sendWelcome({{ alert.user.id }}, this)" title="Wyślij email aktywacyjny">📧 Wyślij zaproszenie</button>
|
||||
{% elif alert.type == 'reset_no_effect' or alert.type == 'repeat_resets' %}
|
||||
<button class="btn-alert-action" onclick="sendReset({{ alert.user.id }}, this)" title="Wyślij nowy reset hasła">🔑 Reset hasła</button>
|
||||
{% endif %}
|
||||
<a href="{{ url_for('admin.user_insights_profile', user_id=alert.user.id, ref_tab=tab, ref_period=period) }}" style="font-size: var(--font-size-sm); color: var(--primary);">Szczegóły →</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
@ -1055,6 +1069,43 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Companies needing attention -->
|
||||
{% if data.companies_attention %}
|
||||
<div class="section-card">
|
||||
<h2>Firmy wymagające uwagi
|
||||
<span class="badge badge-high">{{ data.companies_attention_total }}</span>
|
||||
</h2>
|
||||
<div class="table-scroll" style="max-height: 400px;">
|
||||
<table class="data-table" style="font-size: var(--font-size-sm);">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Firma</th>
|
||||
<th>Braki</th>
|
||||
<th style="text-align: center;">Jakość</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for co in data.companies_attention %}
|
||||
<tr>
|
||||
<td style="font-weight: 500; max-width: 200px;">{{ co.name }}</td>
|
||||
<td>
|
||||
{% for issue in co.issues %}
|
||||
<span class="issue-tag">{{ issue }}</span>
|
||||
{% endfor %}
|
||||
</td>
|
||||
<td style="text-align: center;">
|
||||
<span class="badge {% if co.quality == 'complete' %}badge-ok{% elif co.quality == 'enhanced' %}badge-medium{% else %}badge-high{% endif %}">{{ co.quality }}</span>
|
||||
</td>
|
||||
<td><a href="/company/{{ co.id }}/edit" style="font-size: var(--font-size-xs); color: var(--primary);">Edytuj →</a></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- TAB: CHAT & CONVERSIONS -->
|
||||
<!-- ============================================================ -->
|
||||
@ -1343,4 +1394,49 @@ document.querySelectorAll('.btn-reset-pw').forEach(function(btn) {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Alert action buttons
|
||||
async function sendWelcome(userId, btn) {
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Wysyłanie...';
|
||||
try {
|
||||
const resp = await fetch('/admin/analytics/send-welcome/' + userId, {
|
||||
method: 'POST',
|
||||
headers: {'X-CSRFToken': document.querySelector('meta[name=csrf-token]')?.content || ''}
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (data.success) {
|
||||
btn.textContent = '✓ Wysłano';
|
||||
btn.classList.add('sent');
|
||||
} else {
|
||||
btn.textContent = '✗ ' + (data.error || 'Błąd');
|
||||
btn.disabled = false;
|
||||
}
|
||||
} catch(e) {
|
||||
btn.textContent = '✗ Błąd';
|
||||
btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function sendReset(userId, btn) {
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Wysyłanie...';
|
||||
try {
|
||||
const resp = await fetch('/admin/analytics/send-reset/' + userId, {
|
||||
method: 'POST',
|
||||
headers: {'X-CSRFToken': document.querySelector('meta[name=csrf-token]')?.content || ''}
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (data.success) {
|
||||
btn.textContent = '✓ Wysłano';
|
||||
btn.classList.add('sent');
|
||||
} else {
|
||||
btn.textContent = '✗ ' + (data.error || 'Błąd');
|
||||
btn.disabled = false;
|
||||
}
|
||||
} catch(e) {
|
||||
btn.textContent = '✗ Błąd';
|
||||
btn.disabled = false;
|
||||
}
|
||||
}
|
||||
{% endblock %}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user