feat: add proactive alerts, full chronology, and resolution tracking to User Insights
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

- Proactive alerts in Problems tab: never_logged_in, locked, reset_no_effect, repeat_resets
- 5th stat card showing never-logged-in users count
- Full problem chronology in user profile: audit_logs, emails, sessions, security alerts
- Resolution status card: resolved/pending/blocked/unresolved with time-to-resolution
- Timeline enhanced with detail field, CSS severity classes, and new icon types

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Maciej Pienczyn 2026-02-21 22:41:11 +01:00
parent d21bdd6f65
commit fc9d979fea
3 changed files with 431 additions and 26 deletions

View File

@ -20,7 +20,7 @@ from . import bp
from database import (
SessionLocal, User, UserSession, PageView, SearchQuery,
ConversionEvent, JSError, EmailLog, SecurityAlert,
AnalyticsDaily, SystemRole
AuditLog, AnalyticsDaily, SystemRole
)
from utils.decorators import role_required
@ -173,12 +173,108 @@ def _tab_problems(db, start_date, days):
problem_users.sort(key=lambda x: x['score'], reverse=True)
# Proactive alerts
alerts = []
# Alert: Never logged in (account > 7 days old)
never_logged = db.query(User).filter(
User.is_active == True,
User.last_login.is_(None),
User.created_at < now - timedelta(days=7)
).all()
for u in never_logged:
has_welcome = db.query(EmailLog.id).filter(
EmailLog.recipient_email == u.email,
EmailLog.email_type == 'welcome'
).first() is not None
priority = 'critical' if (u.failed_login_attempts or 0) >= 3 else 'high'
alerts.append({
'type': 'never_logged_in',
'priority': priority,
'user': u,
'message': f'Nigdy nie zalogowany ({(now - u.created_at).days}d od rejestracji)',
'detail': f'Prób logowania: {u.failed_login_attempts or 0}. Email powitalny: {"Tak" if has_welcome else "NIE WYSŁANO"}.',
})
# Alert: Account locked
locked_users = db.query(User).filter(
User.locked_until > now, User.is_active == True
).all()
for u in locked_users:
alerts.append({
'type': 'locked',
'priority': 'critical',
'user': u,
'message': f'Konto zablokowane (do {u.locked_until.strftime("%d.%m %H:%M")})',
'detail': f'Nieudane próby: {u.failed_login_attempts or 0}.',
})
# Alert: Reset without effect (reset sent > 24h ago, no login after)
recent_resets = db.query(
EmailLog.recipient_email,
func.max(EmailLog.created_at).label('last_reset')
).filter(
EmailLog.email_type == 'password_reset',
EmailLog.created_at >= start_30d,
EmailLog.status == 'sent'
).group_by(EmailLog.recipient_email).all()
for r in recent_resets:
u = db.query(User).filter(User.email == r.recipient_email, User.is_active == True).first()
if not u:
continue
# Check if user logged in AFTER the reset
login_after = db.query(AuditLog.id).filter(
AuditLog.user_email == u.email,
AuditLog.action == 'login',
AuditLog.created_at > r.last_reset
).first()
if login_after is None and r.last_reset < now - timedelta(hours=24):
alerts.append({
'type': 'reset_no_effect',
'priority': 'high',
'user': u,
'message': f'Reset hasła bez efektu (wysłany {r.last_reset.strftime("%d.%m %H:%M")})',
'detail': 'Użytkownik nie zalogował się po otrzymaniu emaila z resetem hasła.',
})
# Alert: Repeated resets (>= 3 in 7 days)
repeat_resets = db.query(
EmailLog.recipient_email,
func.count(EmailLog.id).label('cnt')
).filter(
EmailLog.email_type == 'password_reset',
EmailLog.created_at >= start_dt
).group_by(EmailLog.recipient_email).having(func.count(EmailLog.id) >= 3).all()
for r in repeat_resets:
u = db.query(User).filter(User.email == r.recipient_email, User.is_active == True).first()
if u:
# Skip if already in alerts
if not any(a['user'].id == u.id and a['type'] == 'reset_no_effect' for a in alerts):
alerts.append({
'type': 'repeat_resets',
'priority': 'high',
'user': u,
'message': f'{r.cnt} resetów hasła w {days}d',
'detail': 'Wielokrotne resety mogą wskazywać na problem z emailem lub hasłem.',
})
# Sort alerts: critical first, then high
priority_order = {'critical': 0, 'high': 1, 'medium': 2}
alerts.sort(key=lambda a: priority_order.get(a['priority'], 3))
# Stat: never logged in count
never_logged_count = len(never_logged)
return {
'locked_accounts': locked_accounts,
'failed_logins': failed_logins_7d,
'password_resets': password_resets_7d,
'js_errors': js_errors_7d,
'never_logged_in': never_logged_count,
'problem_users': problem_users[:50],
'alerts': alerts,
}
@ -730,22 +826,105 @@ def user_insights_profile(user_id):
fl * 10 + pr_30d * 15 + je_7d * 3 + sp_7d * 2 + sa_7d * 20 + is_locked * 40
)
# Timeline (last 100 events)
# ============================================================
# FULL PROBLEM CHRONOLOGY (audit_logs + email_logs + sessions)
# ============================================================
timeline = []
# Recent sessions (logins)
# Audit logs: login attempts (successful and failed)
audit_entries = db.query(AuditLog).filter(
AuditLog.user_email == user.email
).order_by(desc(AuditLog.created_at)).limit(50).all()
for a in audit_entries:
if a.action == 'login':
timeline.append({
'type': 'login',
'icon': 'key',
'time': a.created_at,
'desc': f'Zalogowano pomyślnie',
'detail': f'IP: {a.ip_address or "?"}',
'css': 'success',
})
elif a.action == 'login_failed':
timeline.append({
'type': 'problem',
'icon': 'x',
'time': a.created_at,
'desc': f'Nieudane logowanie',
'detail': f'IP: {a.ip_address or "?"}',
'css': 'danger',
})
elif a.action == 'email_verified':
timeline.append({
'type': 'login',
'icon': 'check',
'time': a.created_at,
'desc': 'Email zweryfikowany',
'detail': '',
'css': 'success',
})
elif a.action == 'logout':
timeline.append({
'type': 'info',
'icon': 'logout',
'time': a.created_at,
'desc': 'Wylogowanie',
'detail': '',
'css': 'muted',
})
else:
timeline.append({
'type': 'info',
'icon': 'info',
'time': a.created_at,
'desc': f'{a.action}',
'detail': f'{a.entity_type or ""} {a.entity_name or ""}',
'css': 'muted',
})
# All emails sent to this user
all_emails = db.query(EmailLog).filter(
EmailLog.recipient_email == user.email
).order_by(desc(EmailLog.created_at)).limit(30).all()
for e in all_emails:
email_labels = {
'password_reset': 'Reset hasła',
'welcome': 'Email powitalny',
'notification': 'Powiadomienie',
'forum_notification': 'Powiadomienie z forum',
'role_notification': 'Zmiana roli',
'registration_notification': 'Rejestracja',
}
label = email_labels.get(e.email_type, e.email_type)
status_label = {'sent': 'wysłany', 'failed': 'BŁĄD', 'pending': 'oczekuje'}.get(e.status, e.status)
css = 'warning' if e.email_type == 'password_reset' else 'info'
if e.status == 'failed':
css = 'danger'
timeline.append({
'type': 'email',
'icon': 'mail',
'time': e.created_at,
'desc': f'Email: {label} ({status_label})',
'detail': e.subject or '',
'css': css,
})
# Sessions (browser/device context)
sessions = db.query(UserSession).filter(
UserSession.user_id == user_id
).order_by(desc(UserSession.started_at)).limit(20).all()
for s in sessions:
dur = f', {s.duration_seconds // 60}min' if s.duration_seconds else ''
timeline.append({
'type': 'login',
'icon': 'key',
'icon': 'monitor',
'time': s.started_at,
'desc': f'Sesja ({s.device_type or "?"}, {s.browser or "?"})',
'desc': f'Sesja: {s.device_type or "?"} / {s.browser or "?"} / {s.os or "?"}',
'detail': f'{s.page_views_count or 0} stron, {s.clicks_count or 0} kliknięć{dur}',
'css': 'info',
})
# Recent page views (key pages only)
# Key page views
key_paths = ['/', '/forum', '/chat', '/search', '/admin', '/events', '/membership']
recent_pvs = db.query(PageView).filter(
PageView.user_id == user_id,
@ -753,14 +932,19 @@ def user_insights_profile(user_id):
for pv in recent_pvs:
is_key = any(pv.path == p or pv.path.startswith(p + '/') for p in key_paths)
if is_key or '/company/' in pv.path:
load_info = ''
if pv.load_time_ms and pv.load_time_ms > 3000:
load_info = f' (WOLNE: {pv.load_time_ms}ms)'
timeline.append({
'type': 'pageview',
'icon': 'eye',
'time': pv.viewed_at,
'desc': f'Odwiedzono: {pv.path}',
'desc': f'Odwiedzono: {pv.path}{load_info}',
'detail': '',
'css': 'danger' if load_info else 'muted',
})
# Recent searches
# Searches
searches = db.query(SearchQuery).filter(
SearchQuery.user_id == user_id
).order_by(desc(SearchQuery.searched_at)).limit(10).all()
@ -770,6 +954,8 @@ def user_insights_profile(user_id):
'icon': 'search',
'time': s.searched_at,
'desc': f'Szukano: "{s.query}"',
'detail': f'{s.results_count} wyników' if s.results_count else 'Brak wyników',
'css': 'muted' if s.has_results else 'warning',
})
# Conversions
@ -782,35 +968,140 @@ def user_insights_profile(user_id):
'icon': 'check',
'time': c.converted_at,
'desc': f'Konwersja: {c.event_type}',
})
# Password resets
resets = db.query(EmailLog).filter(
EmailLog.recipient_email == user.email,
EmailLog.email_type == 'password_reset'
).order_by(desc(EmailLog.created_at)).limit(5).all()
for r in resets:
timeline.append({
'type': 'problem',
'icon': 'alert',
'time': r.created_at,
'desc': 'Reset hasła',
'detail': c.target_type or '',
'css': 'success',
})
# Security alerts
alerts = db.query(SecurityAlert).filter(
sec_alerts = db.query(SecurityAlert).filter(
SecurityAlert.user_email == user.email
).order_by(desc(SecurityAlert.created_at)).limit(10).all()
for a in alerts:
for a in sec_alerts:
timeline.append({
'type': 'problem',
'icon': 'shield',
'time': a.created_at,
'desc': f'Alert: {a.alert_type} ({a.severity})',
'detail': f'IP: {a.ip_address or "?"}',
'css': 'danger' if a.severity in ('high', 'critical') else 'warning',
})
# Account creation event
if user.created_at:
has_welcome = db.query(EmailLog.id).filter(
EmailLog.recipient_email == user.email,
EmailLog.email_type == 'welcome'
).first() is not None
timeline.append({
'type': 'info',
'icon': 'user',
'time': user.created_at,
'desc': 'Konto utworzone',
'detail': f'Email powitalny: {"Tak" if has_welcome else "NIE WYSŁANO"}',
'css': 'info' if has_welcome else 'danger',
})
timeline.sort(key=lambda x: x['time'], reverse=True)
timeline = timeline[:100]
timeline = timeline[:150]
# ============================================================
# PROBLEM RESOLUTION STATUS
# ============================================================
resolution = None
has_problems = (fl > 0 or pr_30d > 0 or is_locked)
if has_problems or user.last_login is None:
# Find first symptom
first_failed = db.query(func.min(AuditLog.created_at)).filter(
AuditLog.user_email == user.email,
AuditLog.action == 'login_failed'
).scalar()
first_reset = db.query(func.min(EmailLog.created_at)).filter(
EmailLog.recipient_email == user.email,
EmailLog.email_type == 'password_reset'
).scalar()
first_symptom = None
if first_failed and first_reset:
first_symptom = min(first_failed, first_reset)
else:
first_symptom = first_failed or first_reset
# What was sent
all_resets = db.query(EmailLog).filter(
EmailLog.recipient_email == user.email,
EmailLog.email_type == 'password_reset'
).order_by(EmailLog.created_at).all()
# Did user login after last reset?
last_reset_time = all_resets[-1].created_at if all_resets else None
login_after_reset = None
if last_reset_time:
login_after_reset = db.query(AuditLog).filter(
AuditLog.user_email == user.email,
AuditLog.action == 'login',
AuditLog.created_at > last_reset_time
).first()
# Has active token?
has_active_token = (
user.reset_token is not None and
user.reset_token_expires is not None and
user.reset_token_expires > now
)
# Determine status
if user.last_login and (not last_reset_time or user.last_login > last_reset_time):
status = 'resolved'
status_label = 'Rozwiązany'
elif login_after_reset:
status = 'resolved'
status_label = 'Rozwiązany'
elif has_active_token:
status = 'pending'
status_label = f'Oczekuje (token ważny do {user.reset_token_expires.strftime("%d.%m %H:%M")})'
elif is_locked:
status = 'blocked'
status_label = f'Zablokowany (do {user.locked_until.strftime("%d.%m %H:%M")})'
elif all_resets and not login_after_reset:
status = 'unresolved'
status_label = 'Nierozwiązany (token wygasł, brak loginu)'
elif user.last_login is None:
status = 'unresolved'
status_label = 'Nigdy nie zalogowany'
else:
status = 'unknown'
status_label = 'Nieznany'
# Time to resolution
duration = None
if status == 'resolved' and first_symptom:
resolved_at = login_after_reset.created_at if login_after_reset else user.last_login
if resolved_at:
delta = resolved_at - first_symptom
hours = delta.total_seconds() / 3600
if hours < 1:
duration = f'{int(delta.total_seconds() / 60)} min'
elif hours < 24:
duration = f'{hours:.1f} godz.'
else:
duration = f'{delta.days} dni'
resolution = {
'status': status,
'status_label': status_label,
'first_symptom': first_symptom,
'resets_sent': len(all_resets),
'last_reset': last_reset_time,
'login_after_reset': login_after_reset is not None,
'has_active_token': has_active_token,
'duration': duration,
'has_welcome_email': db.query(EmailLog.id).filter(
EmailLog.recipient_email == user.email,
EmailLog.email_type == 'welcome'
).first() is not None,
}
# Favorite pages (top 10)
fav_pages = db.query(
@ -921,6 +1212,7 @@ def user_insights_profile(user_id):
avg_sessions_week=avg_sessions_week,
avg_session_duration=int(avg_session_dur),
search_queries=searches,
resolution=resolution,
)
except Exception as e:
logger.error(f"User insights profile error: {e}", exc_info=True)

View File

@ -119,6 +119,22 @@
/* Table scroll */
.table-scroll { max-height: 500px; overflow-y: auto; }
/* Alerts */
.alert-card { display: flex; align-items: flex-start; gap: var(--spacing-md); padding: var(--spacing-md) var(--spacing-lg); border-radius: var(--radius); margin-bottom: var(--spacing-sm); }
.alert-card.critical { background: #fef2f2; border-left: 4px solid #dc2626; }
.alert-card.high { background: #fffbeb; border-left: 4px solid #f59e0b; }
.alert-card.medium { background: #eff6ff; border-left: 4px solid #3b82f6; }
.alert-icon { width: 32px; height: 32px; border-radius: 50%; display: flex; align-items: center; justify-content: center; flex-shrink: 0; font-size: 14px; }
.alert-card.critical .alert-icon { background: #fee2e2; }
.alert-card.high .alert-icon { background: #fef3c7; }
.alert-card.medium .alert-icon { background: #dbeafe; }
.alert-body { flex: 1; }
.alert-message { font-size: var(--font-size-sm); font-weight: 600; color: var(--text-primary); }
.alert-detail { font-size: var(--font-size-xs); color: var(--text-secondary); margin-top: 2px; }
.alert-action { flex-shrink: 0; }
.alert-action a { padding: 4px 12px; background: white; border: 1px solid var(--border); border-radius: var(--radius-sm); font-size: var(--font-size-xs); color: var(--primary); text-decoration: none; }
.alert-action a:hover { background: var(--background); }
/* Responsive */
@media (max-width: 768px) {
.insights-header { flex-direction: column; align-items: flex-start; }
@ -181,8 +197,35 @@
<div class="stat-value">{{ data.js_errors }}</div>
<div class="stat-label">Błędy JS</div>
</div>
<div class="stat-card error">
<div class="stat-value">{{ data.never_logged_in }}</div>
<div class="stat-label">Nigdy nie zalogowani</div>
</div>
</div>
<!-- Proactive alerts -->
{% if data.alerts %}
<div class="section-card">
<h2>Alerty proaktywne
<span class="badge badge-high">{{ data.alerts|length }}</span>
</h2>
{% for alert in data.alerts %}
<div class="alert-card {{ alert.priority }}">
<div class="alert-icon">
{% if alert.type == 'never_logged_in' %}👤{% elif alert.type == 'locked' %}🔒{% elif alert.type == 'reset_no_effect' %}📧{% elif alert.type == 'repeat_resets' %}🔄{% else %}⚠{% endif %}
</div>
<div class="alert-body">
<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) }}">Szczegóły →</a>
</div>
</div>
{% endfor %}
</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 %}">

View File

@ -54,10 +54,33 @@
.timeline-dot.search { background: #fef3c7; color: #d97706; }
.timeline-dot.conversion { background: #ede9fe; color: #7c3aed; }
.timeline-dot.problem { background: #fee2e2; color: #dc2626; }
.timeline-dot.email { background: #fef3c7; color: #d97706; }
.timeline-dot.info { background: #f3f4f6; color: #6b7280; }
.timeline-body { flex: 1; }
.timeline-desc { font-size: var(--font-size-sm); }
.timeline-desc.css-danger { color: #dc2626; font-weight: 600; }
.timeline-desc.css-warning { color: #d97706; }
.timeline-desc.css-success { color: #16a34a; }
.timeline-detail { font-size: var(--font-size-xs); color: var(--text-muted); margin-top: 1px; }
.timeline-time { font-size: var(--font-size-xs); color: var(--text-muted); }
/* Resolution status */
.resolution-card { padding: var(--spacing-lg); border-radius: var(--radius-lg); margin-bottom: var(--spacing-xl); }
.resolution-card.resolved { background: #f0fdf4; border: 1px solid #86efac; }
.resolution-card.pending { background: #fffbeb; border: 1px solid #fcd34d; }
.resolution-card.blocked { background: #fef2f2; border: 1px solid #fca5a5; }
.resolution-card.unresolved { background: #fef2f2; border: 1px solid #fca5a5; }
.resolution-card.unknown { background: #f9fafb; border: 1px solid #e5e7eb; }
.resolution-header { display: flex; align-items: center; gap: var(--spacing-md); margin-bottom: var(--spacing-md); }
.resolution-status { font-size: var(--font-size-lg); font-weight: 700; }
.resolution-card.resolved .resolution-status { color: #16a34a; }
.resolution-card.pending .resolution-status { color: #d97706; }
.resolution-card.blocked .resolution-status, .resolution-card.unresolved .resolution-status { color: #dc2626; }
.resolution-details { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: var(--spacing-md); }
.resolution-detail-item { font-size: var(--font-size-sm); }
.resolution-detail-label { font-size: var(--font-size-xs); color: var(--text-secondary); text-transform: uppercase; letter-spacing: 0.5px; }
.resolution-detail-value { font-weight: 600; margin-top: 2px; }
/* Bars */
.bar-row { display: flex; align-items: center; gap: var(--spacing-sm); margin-bottom: var(--spacing-sm); }
.bar-label { width: 200px; min-width: 200px; font-size: var(--font-size-xs); font-family: monospace; word-break: break-all; color: var(--text-secondary); }
@ -174,6 +197,52 @@
</div>
</div>
<!-- Resolution status -->
{% if resolution %}
<div class="resolution-card {{ resolution.status }}">
<div class="resolution-header">
{% if resolution.status == 'resolved' %}✅{% elif resolution.status == 'pending' %}⏳{% elif resolution.status == 'blocked' %}🔒{% elif resolution.status == 'unresolved' %}❌{% else %}❓{% endif %}
<div class="resolution-status">{{ resolution.status_label }}</div>
</div>
<div class="resolution-details">
{% if resolution.first_symptom %}
<div class="resolution-detail-item">
<div class="resolution-detail-label">Pierwszy objaw</div>
<div class="resolution-detail-value">{{ resolution.first_symptom.strftime('%d.%m.%Y %H:%M') }}</div>
</div>
{% endif %}
<div class="resolution-detail-item">
<div class="resolution-detail-label">Resety hasła wysłane</div>
<div class="resolution-detail-value">{{ resolution.resets_sent }}</div>
</div>
{% if resolution.last_reset %}
<div class="resolution-detail-item">
<div class="resolution-detail-label">Ostatni reset</div>
<div class="resolution-detail-value">{{ resolution.last_reset.strftime('%d.%m.%Y %H:%M') }}</div>
</div>
{% endif %}
<div class="resolution-detail-item">
<div class="resolution-detail-label">Zalogowano po resecie</div>
<div class="resolution-detail-value">{{ 'Tak' if resolution.login_after_reset else 'Nie' }}</div>
</div>
<div class="resolution-detail-item">
<div class="resolution-detail-label">Aktywny token</div>
<div class="resolution-detail-value">{{ 'Tak' if resolution.has_active_token else 'Nie' }}</div>
</div>
<div class="resolution-detail-item">
<div class="resolution-detail-label">Email powitalny</div>
<div class="resolution-detail-value">{{ 'Tak' if resolution.has_welcome_email else 'NIE WYSŁANO' }}</div>
</div>
{% if resolution.duration %}
<div class="resolution-detail-item">
<div class="resolution-detail-label">Czas do rozwiązania</div>
<div class="resolution-detail-value">{{ resolution.duration }}</div>
</div>
{% endif %}
</div>
</div>
{% endif %}
<div class="two-columns">
<!-- Timeline -->
<div class="section-card">
@ -183,10 +252,11 @@
{% for event in timeline %}
<div class="timeline-item">
<div class="timeline-dot {{ event.type }}">
{% if event.icon == 'key' %}🔑{% elif event.icon == 'eye' %}👁{% elif event.icon == 'search' %}🔍{% elif event.icon == 'check' %}✓{% elif event.icon == 'alert' %}⚠{% elif event.icon == 'shield' %}🛡{% else %}•{% endif %}
{% if event.icon == 'key' %}🔑{% elif event.icon == 'eye' %}👁{% elif event.icon == 'search' %}🔍{% elif event.icon == 'check' %}✓{% elif event.icon == 'alert' %}⚠{% elif event.icon == 'shield' %}🛡{% elif event.icon == 'x' %}✗{% elif event.icon == 'mail' %}📧{% elif event.icon == 'monitor' %}🖥{% elif event.icon == 'logout' %}🚪{% elif event.icon == 'user' %}👤{% elif event.icon == 'info' %}{% else %}•{% endif %}
</div>
<div class="timeline-body">
<div class="timeline-desc">{{ event.desc }}</div>
<div class="timeline-desc {% if event.css == 'danger' %}css-danger{% elif event.css == 'warning' %}css-warning{% elif event.css == 'success' %}css-success{% endif %}">{{ event.desc }}</div>
{% if event.detail %}<div class="timeline-detail">{{ event.detail }}</div>{% endif %}
<div class="timeline-time">{{ event.time.strftime('%d.%m.%Y %H:%M') }}</div>
</div>
</div>