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
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:
parent
cca52301a6
commit
2aefbbf331
@ -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)
|
||||
|
||||
14
database/migrations/080_insights_perf_indexes.sql
Normal file
14
database/migrations/080_insights_perf_indexes.sql
Normal 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;
|
||||
@ -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>
|
||||
|
||||
@ -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 }
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Loading…
Reference in New Issue
Block a user