feat: add remediation effectiveness tracking to Problems tab
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

Shows whether password resets and welcome emails led to successful logins:
- Summary cards: success rate, resolved/pending/failed counts, avg time to login
- Detailed table: each action with user, type, date sent, and outcome
- Resolved = user logged in after email, Pending = <48h, Failed = no login

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Maciej Pienczyn 2026-02-22 10:42:59 +01:00
parent 2aefbbf331
commit 01833aa567
2 changed files with 173 additions and 0 deletions

View File

@ -311,6 +311,102 @@ def _tab_problems(db, start_date, days):
never_logged_count = len(never_logged)
# ============================================================
# REMEDIATION EFFECTIVENESS (30d)
# ============================================================
# All password resets and welcome emails sent in last 30d
remediation_emails = db.query(EmailLog).filter(
EmailLog.email_type.in_(['password_reset', 'welcome']),
EmailLog.created_at >= start_30d,
EmailLog.status == 'sent'
).order_by(desc(EmailLog.created_at)).all()
# Batch: first login AFTER each email's sent time, per user
rem_emails_list = [e.recipient_email for e in remediation_emails]
rem_users_map = {}
rem_logins_map = {} # email -> list of (action, timestamp)
if rem_emails_list:
rem_users_map = {u.email: u for u in db.query(User).filter(
User.email.in_(set(rem_emails_list)), User.is_active == True
).all()}
# All successful logins for these users in last 30d
login_rows = db.query(
AuditLog.user_email, AuditLog.created_at
).filter(
AuditLog.user_email.in_(set(rem_emails_list)),
AuditLog.action == 'login',
AuditLog.created_at >= start_30d
).order_by(AuditLog.created_at).all()
for row in login_rows:
rem_logins_map.setdefault(row.user_email, []).append(row.created_at)
remediation_items = []
resolved_count = 0
pending_count = 0
failed_count = 0
resolution_hours = []
for email_log in remediation_emails:
u = rem_users_map.get(email_log.recipient_email)
if not u:
continue
# Find first login AFTER this email was sent
user_logins = rem_logins_map.get(email_log.recipient_email, [])
first_login_after = None
for login_time in user_logins:
if login_time > email_log.created_at:
first_login_after = login_time
break
if first_login_after:
delta = first_login_after - email_log.created_at
hours = delta.total_seconds() / 3600
resolution_hours.append(hours)
if hours < 1:
time_label = f'{int(delta.total_seconds() / 60)} min'
elif hours < 24:
time_label = f'{hours:.1f} godz.'
else:
time_label = f'{delta.days} dni'
result = 'resolved'
result_label = f'Zalogował się po {time_label}'
resolved_count += 1
elif (now - email_log.created_at).total_seconds() < 48 * 3600:
result = 'pending'
result_label = 'Oczekuje'
pending_count += 1
else:
result = 'failed'
result_label = 'Brak loginu'
failed_count += 1
action_labels = {
'password_reset': 'Reset hasła',
'welcome': 'Email powitalny',
}
remediation_items.append({
'user': u,
'action': action_labels.get(email_log.email_type, email_log.email_type),
'sent_at': email_log.created_at,
'result': result,
'result_label': result_label,
})
total_rem = resolved_count + pending_count + failed_count
avg_resolution_h = round(sum(resolution_hours) / len(resolution_hours), 1) if resolution_hours else None
remediation = {
'items': remediation_items[:30],
'total': total_rem,
'resolved': resolved_count,
'pending': pending_count,
'failed': failed_count,
'success_rate': round(resolved_count / total_rem * 100) if total_rem > 0 else 0,
'avg_resolution_hours': avg_resolution_h,
}
return {
'locked_accounts': locked_accounts,
'failed_logins': failed_logins_total,
@ -320,6 +416,7 @@ def _tab_problems(db, start_date, days):
'never_logged_in': never_logged_count,
'problem_users': problem_users[:50],
'alerts': alerts,
'remediation': remediation,
}

View File

@ -230,6 +230,82 @@
</div>
{% endif %}
<!-- Remediation effectiveness -->
{% if data.remediation and data.remediation.total > 0 %}
<div class="section-card">
<h2>Skuteczność działań <small style="font-weight: 400; color: var(--text-muted); font-size: var(--font-size-sm);">(30 dni)</small></h2>
<div class="stats-grid" style="margin-bottom: var(--spacing-lg);">
<div class="stat-card success">
<div class="stat-value">{{ data.remediation.success_rate }}%</div>
<div class="stat-label">Skuteczność</div>
</div>
<div class="stat-card success">
<div class="stat-value">{{ data.remediation.resolved }}</div>
<div class="stat-label">Zalogowali się</div>
</div>
<div class="stat-card warning">
<div class="stat-value">{{ data.remediation.pending }}</div>
<div class="stat-label">Oczekują (< 48h)</div>
</div>
<div class="stat-card error">
<div class="stat-value">{{ data.remediation.failed }}</div>
<div class="stat-label">Brak reakcji</div>
</div>
{% if data.remediation.avg_resolution_hours is not none %}
<div class="stat-card info">
<div class="stat-value">
{% if data.remediation.avg_resolution_hours < 1 %}
{{ (data.remediation.avg_resolution_hours * 60)|int }} min
{% elif data.remediation.avg_resolution_hours < 24 %}
{{ data.remediation.avg_resolution_hours }} godz.
{% else %}
{{ (data.remediation.avg_resolution_hours / 24)|round(1) }} dni
{% endif %}
</div>
<div class="stat-label">Śr. czas do loginu</div>
</div>
{% endif %}
</div>
<div class="table-scroll" style="max-height: 350px;">
<table class="data-table">
<thead>
<tr>
<th>Użytkownik</th>
<th>Akcja</th>
<th>Wysłano</th>
<th>Wynik</th>
</tr>
</thead>
<tbody>
{% for r in data.remediation.items %}
<tr>
<td>
<div class="user-cell">
<div class="user-avatar">{{ r.user.name[0] if r.user.name else '?' }}</div>
<a href="{{ url_for('admin.user_insights_profile', user_id=r.user.id, ref_tab=tab, ref_period=period) }}">{{ r.user.name or r.user.email }}</a>
</div>
</td>
<td>{{ r.action }}</td>
<td>{{ r.sent_at.strftime('%d.%m %H:%M') }}</td>
<td>
{% if r.result == 'resolved' %}
<span class="badge badge-active">{{ r.result_label }}</span>
{% elif r.result == 'pending' %}
<span class="badge badge-medium">{{ r.result_label }}</span>
{% else %}
<span class="badge badge-high">{{ r.result_label }}</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endif %}
<div class="section-card">
<h2>Użytkownicy z problemami
<span class="badge {% if data.problem_users|length > 10 %}badge-high{% elif data.problem_users|length > 0 %}badge-medium{% else %}badge-ok{% endif %}">