fix: eliminate N+1 queries in User Insights, add bot filtering to profile, UX improvements
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

- _tab_problems: 750 queries → ~10 batch queries with GROUP BY
- _tab_engagement: 2550 queries → ~12 batch queries, sparkline in 1 query
- user_insights_profile: 60+ queries → batch trend (2 queries), bot filtering on all metrics
- Stat cards exclude UNAFFILIATED, dormant excludes never-logged-in users
- Engagement status: never-logged=dormant, login<=7d+score>=10=active, 8-30d=at_risk
- Badge CSS: support both at-risk and at_risk class names
- Problems table: added Alerts and Locked columns
- Security alerts stat card in Problems tab
- Back link preserves tab/period context
- Trend chart Y-axis dynamic instead of hardcoded max:30
- Timeline truncation info when >= 150 events
- Migration 080: composite indexes on audit_logs and email_logs

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Maciej Pienczyn 2026-02-22 08:30:47 +01:00
parent cca52301a6
commit 2aefbbf331
4 changed files with 305 additions and 194 deletions

View File

@ -111,60 +111,68 @@ def _tab_problems(db, start_date, days):
User.locked_until > now, User.is_active == True
).scalar() or 0
failed_logins_7d = db.query(func.count(AuditLog.id)).filter(
failed_logins_total = db.query(func.count(AuditLog.id)).filter(
AuditLog.action == 'login_failed',
AuditLog.created_at >= start_dt
).scalar() or 0
password_resets_7d = db.query(func.count(EmailLog.id)).filter(
password_resets_total = db.query(func.count(EmailLog.id)).filter(
EmailLog.email_type == 'password_reset',
EmailLog.created_at >= start_dt
).scalar() or 0
js_errors_7d = db.query(func.count(JSError.id)).filter(
js_errors_total = db.query(func.count(JSError.id)).filter(
JSError.occurred_at >= start_dt
).scalar() or 0
# Problem users - raw data per user
security_alerts_total = db.query(func.count(SecurityAlert.id)).filter(
SecurityAlert.created_at >= start_dt
).scalar() or 0
# Batch queries — replace N+1 per-user loops with GROUP BY
failed_logins_map = dict(db.query(
AuditLog.user_email, func.count(AuditLog.id)
).filter(
AuditLog.action == 'login_failed',
AuditLog.created_at >= start_dt
).group_by(AuditLog.user_email).all())
sec_alerts_map = dict(db.query(
SecurityAlert.user_email, func.count(SecurityAlert.id)
).filter(
SecurityAlert.created_at >= start_dt
).group_by(SecurityAlert.user_email).all())
pw_resets_map = dict(db.query(
EmailLog.recipient_email, func.count(EmailLog.id)
).filter(
EmailLog.email_type == 'password_reset',
EmailLog.created_at >= start_30d
).group_by(EmailLog.recipient_email).all())
js_errors_map = dict(db.query(
UserSession.user_id, func.count(JSError.id)
).join(JSError, JSError.session_id == UserSession.id).filter(
JSError.occurred_at >= start_dt
).group_by(UserSession.user_id).all())
slow_pages_map = dict(db.query(
PageView.user_id, func.count(PageView.id)
).filter(
PageView.viewed_at >= start_dt,
PageView.load_time_ms > 3000
).group_by(PageView.user_id).all())
# Problem users — dict lookups instead of per-user queries
users = db.query(User).filter(User.is_active == True).all()
problem_users = []
for user in users:
# Failed logins (from audit_logs, time-based)
fl = db.query(func.count(AuditLog.id)).filter(
AuditLog.user_email == user.email,
AuditLog.action == 'login_failed',
AuditLog.created_at >= start_dt
).scalar() or 0
# Security alerts 7d
sa_7d = db.query(func.count(SecurityAlert.id)).filter(
SecurityAlert.user_email == user.email,
SecurityAlert.created_at >= start_dt
).scalar() or 0
# Password resets 30d (email_logs.user_id often NULL, match by recipient_email)
pr_30d = db.query(func.count(EmailLog.id)).filter(
EmailLog.recipient_email == user.email,
EmailLog.email_type == 'password_reset',
EmailLog.created_at >= start_30d
).scalar() or 0
# JS errors 7d (via sessions)
je_7d = db.query(func.count(JSError.id)).join(
UserSession, JSError.session_id == UserSession.id
).filter(
UserSession.user_id == user.id,
JSError.occurred_at >= start_dt
).scalar() or 0
# Slow pages 7d
sp_7d = db.query(func.count(PageView.id)).filter(
PageView.user_id == user.id,
PageView.viewed_at >= start_dt,
PageView.load_time_ms > 3000
).scalar() or 0
fl = failed_logins_map.get(user.email, 0)
sa_7d = sec_alerts_map.get(user.email, 0)
pr_30d = pw_resets_map.get(user.email, 0)
je_7d = js_errors_map.get(user.id, 0)
sp_7d = slow_pages_map.get(user.id, 0)
is_locked = 1 if user.locked_until and user.locked_until > now else 0
score = min(100,
@ -200,11 +208,18 @@ def _tab_problems(db, start_date, days):
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,
# Batch: welcome emails for never-logged users
never_logged_emails = {u.email for u in never_logged}
welcomed_emails = set()
if never_logged_emails:
welcomed_emails = set(e for (e,) in db.query(EmailLog.recipient_email).filter(
EmailLog.recipient_email.in_(never_logged_emails),
EmailLog.email_type == 'welcome'
).first() is not None
).all())
for u in never_logged:
has_welcome = u.email in welcomed_emails
priority = 'critical' if (u.failed_login_attempts or 0) >= 3 else 'high'
alerts.append({
'type': 'never_logged_in',
@ -237,17 +252,28 @@ def _tab_problems(db, start_date, days):
EmailLog.status == 'sent'
).group_by(EmailLog.recipient_email).all()
# Batch: user lookups + login-after checks for resets
reset_emails = [r.recipient_email for r in recent_resets]
users_by_email = {}
login_after_map = {}
if reset_emails:
users_by_email = {u.email: u for u in db.query(User).filter(
User.email.in_(reset_emails), User.is_active == True
).all()}
login_after_map = dict(db.query(
AuditLog.user_email, func.max(AuditLog.created_at)
).filter(
AuditLog.user_email.in_(reset_emails),
AuditLog.action == 'login'
).group_by(AuditLog.user_email).all())
for r in recent_resets:
u = db.query(User).filter(User.email == r.recipient_email, User.is_active == True).first()
u = users_by_email.get(r.recipient_email)
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):
last_login_after = login_after_map.get(u.email)
has_login_after = last_login_after is not None and last_login_after > r.last_reset
if not has_login_after and r.last_reset < now - timedelta(hours=24):
alerts.append({
'type': 'reset_no_effect',
'priority': 'high',
@ -266,9 +292,10 @@ def _tab_problems(db, start_date, days):
).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()
u = users_by_email.get(r.recipient_email)
if not u:
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',
@ -282,14 +309,14 @@ def _tab_problems(db, start_date, days):
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,
'failed_logins': failed_logins_total,
'password_resets': password_resets_total,
'js_errors': js_errors_total,
'security_alerts': security_alerts_total,
'never_logged_in': never_logged_count,
'problem_users': problem_users[:50],
'alerts': alerts,
@ -306,102 +333,141 @@ def _tab_engagement(db, start_date, days):
start_dt = datetime.combine(start_date, datetime.min.time())
start_30d = datetime.combine(date.today() - timedelta(days=30), datetime.min.time())
prev_start = datetime.combine(start_date - timedelta(days=days), datetime.min.time())
first_of_month_dt = datetime.combine(date.today().replace(day=1), datetime.min.time())
# Stat cards — SQL queries instead of Python loops, exclude UNAFFILIATED
base_filter = [User.is_active == True, User.role != 'UNAFFILIATED']
# Stat cards
active_7d = db.query(func.count(func.distinct(UserSession.user_id))).filter(
UserSession.user_id.isnot(None),
UserSession.started_at >= start_dt,
UserSession.is_bot == False
).scalar() or 0
all_users = db.query(User).filter(User.is_active == True).all()
new_this_month = db.query(func.count(User.id)).filter(
*base_filter, User.created_at >= first_of_month_dt
).scalar() or 0
at_risk = 0
dormant = 0
new_this_month = 0
first_of_month = date.today().replace(day=1)
at_risk = db.query(func.count(User.id)).filter(
*base_filter,
User.last_login.isnot(None),
User.last_login >= now - timedelta(days=30),
User.last_login < now - timedelta(days=7)
).scalar() or 0
for u in all_users:
if u.created_at and u.created_at.date() >= first_of_month:
new_this_month += 1
if u.last_login:
days_since = (date.today() - u.last_login.date()).days
if 8 <= days_since <= 30:
at_risk += 1
elif days_since > 30:
dormant += 1
elif u.last_login is None:
dormant += 1
# Dormant = last login > 30d ago (NOT including never-logged-in)
dormant = db.query(func.count(User.id)).filter(
*base_filter,
User.last_login.isnot(None),
User.last_login < now - timedelta(days=30)
).scalar() or 0
# Engagement ranking - compute per user
# Batch queries — replace N+1 per-user loops
sess_cur_map = dict(db.query(
UserSession.user_id, func.count(UserSession.id)
).filter(
UserSession.started_at >= start_dt,
UserSession.user_id.isnot(None),
UserSession.is_bot == False
).group_by(UserSession.user_id).all())
pv_cur_map = dict(db.query(
PageView.user_id, func.count(PageView.id)
).join(UserSession, PageView.session_id == UserSession.id).filter(
PageView.viewed_at >= start_dt,
PageView.user_id.isnot(None),
UserSession.is_bot == False
).group_by(PageView.user_id).all())
sess_prev_map = dict(db.query(
UserSession.user_id, func.count(UserSession.id)
).filter(
UserSession.started_at >= prev_start,
UserSession.started_at < start_dt,
UserSession.user_id.isnot(None),
UserSession.is_bot == False
).group_by(UserSession.user_id).all())
pv_prev_map = dict(db.query(
PageView.user_id, func.count(PageView.id)
).join(UserSession, PageView.session_id == UserSession.id).filter(
PageView.viewed_at >= prev_start,
PageView.viewed_at < start_dt,
PageView.user_id.isnot(None),
UserSession.is_bot == False
).group_by(PageView.user_id).all())
# 30d components: sessions, clicks, duration — single query
sess_30d_rows = db.query(
UserSession.user_id,
func.count(UserSession.id).label('cnt'),
func.coalesce(func.sum(UserSession.clicks_count), 0).label('clicks'),
func.coalesce(func.sum(UserSession.duration_seconds), 0).label('dur')
).filter(
UserSession.started_at >= start_30d,
UserSession.user_id.isnot(None),
UserSession.is_bot == False
).group_by(UserSession.user_id).all()
s30_map = {r.user_id: r.cnt for r in sess_30d_rows}
clicks30_map = {r.user_id: r.clicks for r in sess_30d_rows}
dur30_map = {r.user_id: r.dur for r in sess_30d_rows}
pv30_map = dict(db.query(
PageView.user_id, func.count(PageView.id)
).join(UserSession, PageView.session_id == UserSession.id).filter(
PageView.viewed_at >= start_30d,
PageView.user_id.isnot(None),
UserSession.is_bot == False
).group_by(PageView.user_id).all())
conv30_map = dict(db.query(
ConversionEvent.user_id, func.count(ConversionEvent.id)
).filter(
ConversionEvent.converted_at >= start_30d
).group_by(ConversionEvent.user_id).all())
search30_map = dict(db.query(
SearchQuery.user_id, func.count(SearchQuery.id)
).filter(
SearchQuery.searched_at >= start_30d
).group_by(SearchQuery.user_id).all())
# Sparkline — 1 query instead of 7×N
spark_start = datetime.combine(date.today() - timedelta(days=6), datetime.min.time())
sparkline_raw = db.query(
PageView.user_id,
func.date(PageView.viewed_at).label('day'),
func.count(PageView.id).label('cnt')
).join(UserSession, PageView.session_id == UserSession.id).filter(
PageView.viewed_at >= spark_start,
PageView.user_id.isnot(None),
UserSession.is_bot == False
).group_by(PageView.user_id, func.date(PageView.viewed_at)).all()
sparkline_map = {}
for row in sparkline_raw:
sparkline_map.setdefault(row.user_id, {})[row.day] = row.cnt
# Build engagement list from batch data
registered_users = db.query(User).filter(
User.is_active == True, User.role != 'UNAFFILIATED'
).all()
spark_days = [date.today() - timedelta(days=6 - i) for i in range(7)]
engagement_list = []
for user in registered_users:
# Current period (exclude bots)
sessions_cur = db.query(func.count(UserSession.id)).filter(
UserSession.user_id == user.id,
UserSession.started_at >= start_dt,
UserSession.is_bot == False
).scalar() or 0
uid = user.id
sessions_cur = sess_cur_map.get(uid, 0)
pv_cur = pv_cur_map.get(uid, 0)
pv_prev = pv_prev_map.get(uid, 0)
pv_cur = db.query(func.count(PageView.id)).filter(
PageView.user_id == user.id,
PageView.viewed_at >= start_dt,
PageView.session_id.in_(_non_bot_sessions(db, start_dt))
).scalar() or 0
# Previous period for WoW
sessions_prev = db.query(func.count(UserSession.id)).filter(
UserSession.user_id == user.id,
UserSession.started_at >= prev_start,
UserSession.started_at < start_dt,
UserSession.is_bot == False
).scalar() or 0
pv_prev = db.query(func.count(PageView.id)).filter(
PageView.user_id == user.id,
PageView.viewed_at >= prev_start,
PageView.viewed_at < start_dt,
PageView.session_id.in_(_non_bot_sessions(db, prev_start))
).scalar() or 0
# 30d engagement score components (exclude bots)
s30 = db.query(func.count(UserSession.id)).filter(
UserSession.user_id == user.id,
UserSession.started_at >= start_30d,
UserSession.is_bot == False
).scalar() or 0
pv30 = db.query(func.count(PageView.id)).filter(
PageView.user_id == user.id,
PageView.viewed_at >= start_30d,
PageView.session_id.in_(_non_bot_sessions(db, start_30d))
).scalar() or 0
clicks30 = db.query(func.sum(UserSession.clicks_count)).filter(
UserSession.user_id == user.id,
UserSession.started_at >= start_30d,
UserSession.is_bot == False
).scalar() or 0
dur30 = db.query(func.sum(UserSession.duration_seconds)).filter(
UserSession.user_id == user.id,
UserSession.started_at >= start_30d,
UserSession.is_bot == False
).scalar() or 0
conv30 = db.query(func.count(ConversionEvent.id)).filter(
ConversionEvent.user_id == user.id,
ConversionEvent.converted_at >= start_30d
).scalar() or 0
search30 = db.query(func.count(SearchQuery.id)).filter(
SearchQuery.user_id == user.id,
SearchQuery.searched_at >= start_30d
).scalar() or 0
s30 = s30_map.get(uid, 0)
pv30 = pv30_map.get(uid, 0)
clicks30 = clicks30_map.get(uid, 0)
dur30 = dur30_map.get(uid, 0)
conv30 = conv30_map.get(uid, 0)
search30 = search30_map.get(uid, 0)
raw = (s30 * 3 + pv30 * 1 + int(clicks30) * 0.5 +
int(dur30) / 60 * 2 + conv30 * 10 + search30 * 2)
@ -414,30 +480,23 @@ def _tab_engagement(db, start_date, days):
elif pv_cur > 0:
wow = 100
# Status
# Engagement status (improved logic)
days_since_login = None
if user.last_login:
days_since_login = (date.today() - user.last_login.date()).days
if days_since_login is not None and days_since_login <= 7 and score >= 20:
status = 'active'
elif (days_since_login is not None and 8 <= days_since_login <= 30) or (5 <= score < 20):
if days_since_login is None:
status = 'dormant'
elif days_since_login <= 7:
status = 'active' if score >= 10 else 'at_risk'
elif days_since_login <= 30:
status = 'at_risk'
else:
status = 'dormant'
# Daily sparkline (7 days)
sparkline = []
for i in range(7):
d = date.today() - timedelta(days=6 - i)
d_start = datetime.combine(d, datetime.min.time())
d_end = datetime.combine(d + timedelta(days=1), datetime.min.time())
cnt = db.query(func.count(PageView.id)).filter(
PageView.user_id == user.id,
PageView.viewed_at >= d_start,
PageView.viewed_at < d_end
).scalar() or 0
sparkline.append(cnt)
# Sparkline from batch data
user_spark = sparkline_map.get(uid, {})
sparkline = [user_spark.get(d, 0) for d in spark_days]
if sessions_cur > 0 or score > 0:
engagement_list.append({
@ -832,21 +891,31 @@ def user_insights_profile(user_id):
start_30d = datetime.combine(date.today() - timedelta(days=30), datetime.min.time())
start_7d = datetime.combine(date.today() - timedelta(days=7), datetime.min.time())
# Engagement score (30d)
# Back link context
ref_tab = request.args.get('ref_tab', 'engagement')
ref_period = request.args.get('ref_period', 'week')
# Engagement score (30d) — with bot filtering
s30 = db.query(func.count(UserSession.id)).filter(
UserSession.user_id == user_id, UserSession.started_at >= start_30d
UserSession.user_id == user_id, UserSession.started_at >= start_30d,
UserSession.is_bot == False
).scalar() or 0
pv30 = db.query(func.count(PageView.id)).filter(
PageView.user_id == user_id, PageView.viewed_at >= start_30d
pv30 = db.query(func.count(PageView.id)).join(
UserSession, PageView.session_id == UserSession.id
).filter(
PageView.user_id == user_id, PageView.viewed_at >= start_30d,
UserSession.is_bot == False
).scalar() or 0
clicks30 = db.query(func.sum(UserSession.clicks_count)).filter(
UserSession.user_id == user_id, UserSession.started_at >= start_30d
UserSession.user_id == user_id, UserSession.started_at >= start_30d,
UserSession.is_bot == False
).scalar() or 0
dur30 = db.query(func.sum(UserSession.duration_seconds)).filter(
UserSession.user_id == user_id, UserSession.started_at >= start_30d
UserSession.user_id == user_id, UserSession.started_at >= start_30d,
UserSession.is_bot == False
).scalar() or 0
conv30 = db.query(func.count(ConversionEvent.id)).filter(
@ -976,9 +1045,10 @@ def user_insights_profile(user_id):
'css': css,
})
# Sessions (browser/device context)
# Sessions (browser/device context, exclude bots)
sessions = db.query(UserSession).filter(
UserSession.user_id == user_id
UserSession.user_id == user_id,
UserSession.is_bot == False
).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 ''
@ -1170,24 +1240,26 @@ def user_insights_profile(user_id):
).first() is not None,
}
# Favorite pages (top 10)
# Favorite pages (top 10, exclude bots)
fav_pages = db.query(
PageView.path,
func.count(PageView.id).label('cnt')
).filter(
).join(UserSession, PageView.session_id == UserSession.id).filter(
PageView.user_id == user_id,
PageView.viewed_at >= start_30d
PageView.viewed_at >= start_30d,
UserSession.is_bot == False
).group_by(PageView.path).order_by(desc('cnt')).limit(10).all()
max_fav = fav_pages[0].cnt if fav_pages else 1
# Device/browser breakdown
# Device/browser breakdown (exclude bots)
devices = db.query(
UserSession.device_type,
func.count(UserSession.id).label('cnt')
).filter(
UserSession.user_id == user_id,
UserSession.started_at >= start_30d
UserSession.started_at >= start_30d,
UserSession.is_bot == False
).group_by(UserSession.device_type).all()
browsers = db.query(
@ -1195,14 +1267,15 @@ def user_insights_profile(user_id):
func.count(UserSession.id).label('cnt')
).filter(
UserSession.user_id == user_id,
UserSession.started_at >= start_30d
UserSession.started_at >= start_30d,
UserSession.is_bot == False
).group_by(UserSession.browser).order_by(desc('cnt')).limit(5).all()
# Hourly activity pattern (24 bars)
# Hourly activity pattern (24 bars, exclude bots)
hourly_sql = text("""
SELECT EXTRACT(HOUR FROM started_at)::int as hour, COUNT(*) as cnt
FROM user_sessions
WHERE user_id = :uid AND started_at >= :start_dt
WHERE user_id = :uid AND started_at >= :start_dt AND is_bot = false
GROUP BY hour ORDER BY hour
""")
hourly_raw = db.execute(hourly_sql, {'uid': user_id, 'start_dt': start_30d}).fetchall()
@ -1213,26 +1286,33 @@ def user_insights_profile(user_id):
cnt = hourly.get(h, 0)
hourly_bars.append({'hour': h, 'count': cnt, 'pct': int(cnt / max_hourly * 100)})
# Daily engagement trend (30d for Chart.js)
# Daily engagement trend (30d for Chart.js) — 2 batch queries instead of 60
trend_start = datetime.combine(date.today() - timedelta(days=29), datetime.min.time())
trend_sessions = dict(db.query(
func.date(UserSession.started_at).label('day'),
func.count(UserSession.id)
).filter(
UserSession.user_id == user_id,
UserSession.started_at >= trend_start,
UserSession.is_bot == False
).group_by(func.date(UserSession.started_at)).all())
trend_pvs = dict(db.query(
func.date(PageView.viewed_at).label('day'),
func.count(PageView.id)
).join(UserSession, PageView.session_id == UserSession.id).filter(
PageView.user_id == user_id,
PageView.viewed_at >= trend_start,
UserSession.is_bot == False
).group_by(func.date(PageView.viewed_at)).all())
trend_labels = []
trend_scores = []
for i in range(30):
d = date.today() - timedelta(days=29 - i)
d_start = datetime.combine(d, datetime.min.time())
d_end = datetime.combine(d + timedelta(days=1), datetime.min.time())
d_sessions = db.query(func.count(UserSession.id)).filter(
UserSession.user_id == user_id,
UserSession.started_at >= d_start,
UserSession.started_at < d_end
).scalar() or 0
d_pv = db.query(func.count(PageView.id)).filter(
PageView.user_id == user_id,
PageView.viewed_at >= d_start,
PageView.viewed_at < d_end
).scalar() or 0
d_sessions = trend_sessions.get(d, 0)
d_pv = trend_pvs.get(d, 0)
daily_score = _log_engagement_score(d_sessions * 3 + d_pv)
trend_labels.append(d.strftime('%d.%m'))
trend_scores.append(daily_score)
@ -1249,16 +1329,18 @@ def user_insights_profile(user_id):
PageView.load_time_ms > 3000
).order_by(desc(PageView.viewed_at)).limit(10).all()
# Avg sessions per week
# Avg sessions per week (exclude bots)
weeks_active = max(1, (date.today() - (user.created_at.date() if user.created_at else date.today())).days / 7)
total_sessions_all = db.query(func.count(UserSession.id)).filter(
UserSession.user_id == user_id
UserSession.user_id == user_id,
UserSession.is_bot == False
).scalar() or 0
avg_sessions_week = round(total_sessions_all / weeks_active, 1)
avg_session_dur = db.query(func.avg(UserSession.duration_seconds)).filter(
UserSession.user_id == user_id,
UserSession.duration_seconds.isnot(None)
UserSession.duration_seconds.isnot(None),
UserSession.is_bot == False
).scalar() or 0
return render_template(
@ -1280,6 +1362,8 @@ def user_insights_profile(user_id):
avg_session_duration=int(avg_session_dur),
search_queries=searches,
resolution=resolution,
ref_tab=ref_tab,
ref_period=ref_period,
)
except Exception as e:
logger.error(f"User insights profile error: {e}", exc_info=True)

View File

@ -0,0 +1,14 @@
-- Migration 080: Performance indexes for User Insights dashboard
-- Addresses slow queries in _tab_problems and _tab_engagement
-- audit_logs: user_email + action + created_at (problem score, failed logins)
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_al_email_action_date
ON audit_logs(user_email, action, created_at DESC);
-- email_logs: recipient_email + email_type + created_at (password resets)
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_el_recipient_type_date
ON email_logs(recipient_email, email_type, created_at DESC);
-- Grant permissions
GRANT ALL ON TABLE audit_logs TO nordabiz_app;
GRANT ALL ON TABLE email_logs TO nordabiz_app;

View File

@ -55,7 +55,7 @@
.badge-low { background: #e0f2fe; color: #0369a1; }
.badge-ok { background: #dcfce7; color: #166534; }
.badge-active { background: #dcfce7; color: #166534; }
.badge-at-risk { background: #fef3c7; color: #92400e; }
.badge-at-risk, .badge-at_risk { background: #fef3c7; color: #92400e; }
.badge-dormant { background: #fee2e2; color: #991b1b; }
/* Sparkline */
@ -197,6 +197,10 @@
<div class="stat-value">{{ data.js_errors }}</div>
<div class="stat-label">Błędy JS</div>
</div>
<div class="stat-card warning">
<div class="stat-value">{{ data.security_alerts }}</div>
<div class="stat-label">Alerty bezpieczeństwa</div>
</div>
<div class="stat-card error">
<div class="stat-value">{{ data.never_logged_in }}</div>
<div class="stat-label">Nigdy nie zalogowani</div>
@ -219,7 +223,7 @@
<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>
<a href="{{ url_for('admin.user_insights_profile', user_id=alert.user.id, ref_tab=tab, ref_period=period) }}">Szczegóły →</a>
</div>
</div>
{% endfor %}
@ -242,8 +246,10 @@
<th>Problem Score</th>
<th>Nieudane logowania</th>
<th>Resety hasła</th>
<th>Alerty</th>
<th>Błędy JS</th>
<th>Wolne strony</th>
<th>🔒</th>
<th>Ostatni login</th>
</tr>
</thead>
@ -253,7 +259,7 @@
<td>
<div class="user-cell">
<div class="user-avatar">{{ p.user.name[0] if p.user.name else '?' }}</div>
<a href="{{ url_for('admin.user_insights_profile', user_id=p.user.id) }}">{{ p.user.name or p.user.email }}</a>
<a href="{{ url_for('admin.user_insights_profile', user_id=p.user.id, ref_tab=tab, ref_period=period) }}">{{ p.user.name or p.user.email }}</a>
</div>
</td>
<td>
@ -263,8 +269,10 @@
</td>
<td>{{ p.failed_logins }}</td>
<td>{{ p.password_resets }}</td>
<td>{{ p.security_alerts }}</td>
<td>{{ p.js_errors }}</td>
<td>{{ p.slow_pages }}</td>
<td>{% if p.is_locked %}<span class="badge badge-critical">Tak</span>{% endif %}</td>
<td>{{ p.last_login.strftime('%d.%m %H:%M') if p.last_login else 'Nigdy' }}</td>
</tr>
{% endfor %}
@ -326,7 +334,7 @@
<td>
<div class="user-cell">
<div class="user-avatar">{{ e.user.name[0] if e.user.name else '?' }}</div>
<a href="{{ url_for('admin.user_insights_profile', user_id=e.user.id) }}">{{ e.user.name or e.user.email }}</a>
<a href="{{ url_for('admin.user_insights_profile', user_id=e.user.id, ref_tab=tab, ref_period=period) }}">{{ e.user.name or e.user.email }}</a>
</div>
</td>
<td><strong>{{ e.score }}</strong></td>
@ -573,7 +581,7 @@
<!-- Sessions + Page Views chart -->
<div class="section-card">
<h2>Sesje i odsłony (30 dni)</h2>
<h2>Sesje i odsłony <small style="font-weight: 400; color: var(--text-muted); font-size: var(--font-size-sm);">(zawsze ostatnie 30 dni)</small></h2>
<div class="chart-container">
<canvas id="sessionsChart"></canvas>
</div>

View File

@ -120,7 +120,7 @@
{% block content %}
<div class="profile-container">
<a href="{{ url_for('admin.user_insights', tab='engagement') }}" class="back-link">
<a href="{{ url_for('admin.user_insights', tab=ref_tab, period=ref_period) }}" class="back-link">
<svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M19 12H5M12 19l-7-7 7-7"/></svg>
Powrót do User Insights
</a>
@ -248,6 +248,11 @@
<div class="section-card">
<h2>Oś czasu aktywności</h2>
{% if timeline %}
{% if timeline|length >= 150 %}
<p style="color: var(--text-muted); text-align: center; font-size: 0.8rem; margin-bottom: var(--spacing-sm);">
Wyświetlono ostatnie 150 zdarzeń. Starsze zdarzenia są ukryte.
</p>
{% endif %}
<div class="timeline" style="max-height: 600px; overflow-y: auto;">
{% for event in timeline %}
<div class="timeline-item">
@ -426,7 +431,7 @@
plugins: { legend: { display: false } },
scales: {
x: { grid: { display: false } },
y: { beginAtZero: true, max: 30 }
y: { beginAtZero: true, suggestedMax: Math.max(30, ...trendData.scores) + 5 }
}
}
});