feat(admin): sortable user table + user activity analytics panel
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

1. /admin/users — clickable column headers for sorting by ID, name,
   company, created date, last login. Arrows indicate sort direction.

2. /admin/user-activity — new analytics panel showing:
   - Summary stats (sessions, unique users, avg duration, pageviews)
   - Daily active users chart (CSS-only, 30 days)
   - Recent logins table (user, device, duration, pages)
   - Most visited pages
   - Most active users ranking

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Maciej Pienczyn 2026-03-18 08:28:52 +01:00
parent 805471fe00
commit 5fa730f5a5
4 changed files with 654 additions and 7 deletions

View File

@ -35,3 +35,4 @@ from . import routes_bulk_enrichment # noqa: E402, F401
from . import routes_website_discovery # noqa: E402, F401
from . import routes_portal_seo # noqa: E402, F401
from . import routes_user_insights # noqa: E402, F401
from . import routes_user_activity # noqa: E402, F401

View File

@ -0,0 +1,205 @@
"""
Admin User Activity Routes
============================
User activity dashboard - sessions, logins, page views, active users chart.
"""
import logging
from datetime import date, datetime, timedelta
from collections import defaultdict
from flask import render_template, request, redirect, url_for, flash
from flask_login import login_required
from sqlalchemy import func, desc, cast, Date
from . import bp
from database import (
SessionLocal, User, UserSession, PageView, SystemRole
)
from utils.decorators import role_required
logger = logging.getLogger(__name__)
@bp.route('/user-activity')
@login_required
@role_required(SystemRole.ADMIN)
def admin_user_activity():
"""User activity dashboard - sessions, logins, pages, daily active users."""
db = SessionLocal()
try:
today = date.today()
start_date = today - timedelta(days=30)
start_dt = datetime.combine(start_date, datetime.min.time())
# ----------------------------------------------------------
# SUMMARY STATS (last 30 days, non-bot only)
# ----------------------------------------------------------
summary_row = db.query(
func.count(UserSession.id).label('total_sessions'),
func.count(func.distinct(UserSession.user_id)).label('unique_users'),
func.coalesce(func.avg(UserSession.duration_seconds), 0).label('avg_duration'),
).filter(
UserSession.started_at >= start_dt,
UserSession.is_bot == False,
UserSession.user_id.isnot(None),
).first()
total_sessions = summary_row.total_sessions or 0
unique_users = summary_row.unique_users or 0
avg_duration_sec = int(summary_row.avg_duration or 0)
total_pageviews = db.query(func.count(PageView.id)).filter(
PageView.viewed_at >= start_dt,
).scalar() or 0
summary = {
'total_sessions': total_sessions,
'unique_users': unique_users,
'avg_duration_min': round(avg_duration_sec / 60, 1) if avg_duration_sec else 0,
'total_pageviews': total_pageviews,
}
# ----------------------------------------------------------
# RECENT LOGINS (last 50 sessions with user info)
# ----------------------------------------------------------
recent_sessions_q = (
db.query(
UserSession,
User.name.label('user_name'),
User.email.label('user_email'),
)
.join(User, UserSession.user_id == User.id)
.filter(
UserSession.started_at >= start_dt,
UserSession.is_bot == False,
)
.order_by(UserSession.started_at.desc())
.limit(50)
.all()
)
recent_sessions = []
for sess, user_name, user_email in recent_sessions_q:
duration_min = round((sess.duration_seconds or 0) / 60, 1)
recent_sessions.append({
'user_name': user_name or user_email or '?',
'started_at': sess.started_at,
'device_type': sess.device_type or '-',
'browser': sess.browser or '-',
'duration_min': duration_min,
'page_views_count': sess.page_views_count or 0,
})
# ----------------------------------------------------------
# MOST VISITED PAGES (top 20 paths)
# ----------------------------------------------------------
top_pages_q = (
db.query(
PageView.path,
func.count(PageView.id).label('view_count'),
func.coalesce(func.avg(PageView.time_on_page_seconds), 0).label('avg_time'),
)
.filter(PageView.viewed_at >= start_dt)
.group_by(PageView.path)
.order_by(desc('view_count'))
.limit(20)
.all()
)
top_pages = []
for row in top_pages_q:
top_pages.append({
'path': row.path,
'view_count': row.view_count,
'avg_time_sec': int(row.avg_time or 0),
})
# ----------------------------------------------------------
# MOST ACTIVE USERS (top 20 by session count)
# ----------------------------------------------------------
active_users_q = (
db.query(
User.id,
User.name,
User.email,
User.last_login,
func.count(UserSession.id).label('session_count'),
func.coalesce(func.sum(UserSession.duration_seconds), 0).label('total_time'),
func.coalesce(func.sum(UserSession.page_views_count), 0).label('total_pages'),
)
.join(UserSession, UserSession.user_id == User.id)
.filter(
UserSession.started_at >= start_dt,
UserSession.is_bot == False,
)
.group_by(User.id, User.name, User.email, User.last_login)
.order_by(desc('session_count'))
.limit(20)
.all()
)
active_users = []
for row in active_users_q:
total_min = round((row.total_time or 0) / 60, 1)
active_users.append({
'name': row.name or row.email,
'session_count': row.session_count,
'total_time_min': total_min,
'total_pages': row.total_pages or 0,
'last_login': row.last_login,
})
# ----------------------------------------------------------
# DAILY ACTIVE USERS (last 30 days for chart)
# ----------------------------------------------------------
dau_q = (
db.query(
cast(UserSession.started_at, Date).label('day'),
func.count(func.distinct(UserSession.user_id)).label('users'),
)
.filter(
UserSession.started_at >= start_dt,
UserSession.is_bot == False,
UserSession.user_id.isnot(None),
)
.group_by('day')
.order_by('day')
.all()
)
# Fill in missing days with 0
dau_map = {row.day: row.users for row in dau_q}
daily_active = []
max_dau = 1
for i in range(31):
d = start_date + timedelta(days=i)
count = dau_map.get(d, 0)
if count > max_dau:
max_dau = count
daily_active.append({
'date': d,
'label': d.strftime('%d.%m'),
'count': count,
})
# Add percentage for CSS bar heights
for item in daily_active:
item['pct'] = round(item['count'] / max_dau * 100) if max_dau > 0 else 0
return render_template(
'admin/user_activity.html',
summary=summary,
recent_sessions=recent_sessions,
top_pages=top_pages,
active_users=active_users,
daily_active=daily_active,
)
except Exception as e:
logger.error(f"User activity dashboard error: {e}", exc_info=True)
flash('Blad ladowania aktywnosci uzytkownikow.', 'error')
return redirect(url_for('admin.admin_users'))
finally:
db.close()

View File

@ -0,0 +1,336 @@
{% extends "base.html" %}
{% block title %}Aktywnosc Uzytkownikow - Admin - Norda Biznes Partner{% endblock %}
{% block extra_css %}
<style>
.admin-header {
margin-bottom: var(--spacing-xl);
}
.admin-header h1 {
font-size: var(--font-size-3xl);
color: var(--text-primary);
margin: 0;
}
.admin-header p {
margin: var(--spacing-xs) 0 0 0;
color: var(--text-secondary);
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: var(--spacing-lg);
margin-bottom: var(--spacing-2xl);
}
.stat-card {
background: var(--surface);
padding: var(--spacing-lg);
border-radius: var(--radius-lg);
box-shadow: var(--shadow);
text-align: center;
}
.stat-value {
font-size: var(--font-size-3xl);
font-weight: 700;
color: var(--primary);
}
.stat-label {
color: var(--text-secondary);
font-size: var(--font-size-sm);
margin-top: var(--spacing-xs);
}
.section {
background: var(--surface);
padding: var(--spacing-xl);
border-radius: var(--radius-lg);
box-shadow: var(--shadow);
margin-bottom: var(--spacing-xl);
}
.section h2 {
font-size: var(--font-size-xl);
margin-bottom: var(--spacing-lg);
color: var(--text-primary);
border-bottom: 2px solid var(--border);
padding-bottom: var(--spacing-sm);
}
.data-table {
width: 100%;
border-collapse: collapse;
}
.data-table th,
.data-table td {
padding: var(--spacing-md);
text-align: left;
border-bottom: 1px solid var(--border);
}
.data-table th {
font-weight: 600;
color: var(--text-secondary);
font-size: var(--font-size-sm);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.data-table tr:hover {
background: var(--background);
}
.data-table td.num {
text-align: right;
font-variant-numeric: tabular-nums;
}
.device-badge {
display: inline-block;
padding: 2px 8px;
border-radius: var(--radius-sm);
font-size: var(--font-size-xs);
font-weight: 500;
}
.device-desktop { background: #DBEAFE; color: #1D4ED8; }
.device-mobile { background: #D1FAE5; color: #065F46; }
.device-tablet { background: #FEF3C7; color: #D97706; }
.device-other { background: #F3F4F6; color: #6B7280; }
/* ---- DAU Chart (CSS-only bars) ---- */
.chart-container {
overflow-x: auto;
}
.bar-chart {
display: flex;
align-items: flex-end;
gap: 3px;
height: 180px;
padding-top: var(--spacing-sm);
}
.bar-col {
flex: 1;
min-width: 18px;
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
}
.bar-value {
font-size: 10px;
color: var(--text-secondary);
line-height: 1;
}
.bar {
width: 100%;
background: var(--primary);
border-radius: 3px 3px 0 0;
min-height: 2px;
transition: opacity 0.15s;
}
.bar:hover {
opacity: 0.8;
}
.bar-label {
font-size: 9px;
color: var(--text-secondary);
writing-mode: vertical-rl;
text-orientation: mixed;
transform: rotate(180deg);
height: 40px;
line-height: 1;
}
.text-muted {
color: var(--text-secondary);
font-size: var(--font-size-sm);
}
.table-scroll {
overflow-x: auto;
}
@media (max-width: 768px) {
.stats-grid {
grid-template-columns: repeat(2, 1fr);
}
.bar-chart {
min-width: 600px;
}
.data-table th,
.data-table td {
padding: var(--spacing-sm);
font-size: var(--font-size-sm);
}
}
</style>
{% endblock %}
{% block content %}
<div class="container">
<!-- Header -->
<div class="admin-header">
<h1>Aktywnosc uzytkownikow</h1>
<p>Dane z ostatnich 30 dni (bez botow)</p>
</div>
<!-- Summary stat cards -->
<div class="stats-grid">
<div class="stat-card">
<div class="stat-value">{{ summary.total_sessions }}</div>
<div class="stat-label">Sesje</div>
</div>
<div class="stat-card">
<div class="stat-value">{{ summary.unique_users }}</div>
<div class="stat-label">Unikalni uzytkownicy</div>
</div>
<div class="stat-card">
<div class="stat-value">{{ summary.avg_duration_min }} min</div>
<div class="stat-label">Sredni czas sesji</div>
</div>
<div class="stat-card">
<div class="stat-value">{{ summary.total_pageviews }}</div>
<div class="stat-label">Odslony stron</div>
</div>
</div>
<!-- Daily Active Users chart -->
<div class="section">
<h2>Aktywni uzytkownicy dziennie</h2>
<div class="chart-container">
<div class="bar-chart">
{% for day in daily_active %}
<div class="bar-col" title="{{ day.label }}: {{ day.count }} uzytkownikow">
<span class="bar-value">{% if day.count > 0 %}{{ day.count }}{% endif %}</span>
<div class="bar" style="height: {{ day.pct }}%;"></div>
<span class="bar-label">{{ day.label }}</span>
</div>
{% endfor %}
</div>
</div>
</div>
<!-- Recent logins -->
<div class="section">
<h2>Ostatnie logowania</h2>
{% if recent_sessions %}
<div class="table-scroll">
<table class="data-table">
<thead>
<tr>
<th>Uzytkownik</th>
<th>Data</th>
<th>Urzadzenie</th>
<th>Przegladarka</th>
<th>Czas (min)</th>
<th>Odslony</th>
</tr>
</thead>
<tbody>
{% for s in recent_sessions %}
<tr>
<td>{{ s.user_name }}</td>
<td>{{ s.started_at.strftime('%d.%m.%Y %H:%M') if s.started_at else '-' }}</td>
<td>
{% set dt = s.device_type|lower %}
<span class="device-badge device-{{ dt if dt in ['desktop','mobile','tablet'] else 'other' }}">
{{ s.device_type }}
</span>
</td>
<td>{{ s.browser }}</td>
<td class="num">{{ s.duration_min }}</td>
<td class="num">{{ s.page_views_count }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="text-muted">Brak danych o sesjach w ostatnich 30 dniach.</p>
{% endif %}
</div>
<!-- Most visited pages -->
<div class="section">
<h2>Najczesciej odwiedzane strony</h2>
{% if top_pages %}
<div class="table-scroll">
<table class="data-table">
<thead>
<tr>
<th>#</th>
<th>Sciezka</th>
<th>Odslony</th>
<th>Sredni czas (s)</th>
</tr>
</thead>
<tbody>
{% for p in top_pages %}
<tr>
<td>{{ loop.index }}</td>
<td><code>{{ p.path }}</code></td>
<td class="num">{{ p.view_count }}</td>
<td class="num">{{ p.avg_time_sec }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="text-muted">Brak danych o odslonach stron.</p>
{% endif %}
</div>
<!-- Most active users -->
<div class="section">
<h2>Najbardziej aktywni uzytkownicy</h2>
{% if active_users %}
<div class="table-scroll">
<table class="data-table">
<thead>
<tr>
<th>#</th>
<th>Uzytkownik</th>
<th>Sesje</th>
<th>Laczny czas (min)</th>
<th>Odslony</th>
<th>Ostatnie logowanie</th>
</tr>
</thead>
<tbody>
{% for u in active_users %}
<tr>
<td>{{ loop.index }}</td>
<td>{{ u.name }}</td>
<td class="num">{{ u.session_count }}</td>
<td class="num">{{ u.total_time_min }}</td>
<td class="num">{{ u.total_pages }}</td>
<td>{{ u.last_login.strftime('%d.%m.%Y %H:%M') if u.last_login else '-' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="text-muted">Brak danych o aktywnosci uzytkownikow.</p>
{% endif %}
</div>
</div>
{% endblock %}

View File

@ -1076,6 +1076,36 @@
.ai-modal-footer .btn {
min-width: 120px;
}
/* Sortable table headers */
.users-table th[data-sort-key] {
cursor: pointer;
user-select: none;
position: relative;
padding-right: calc(var(--spacing-md) + 16px);
}
.users-table th[data-sort-key]:hover {
color: var(--primary);
}
.users-table th[data-sort-key]::after {
content: '⇅';
position: absolute;
right: var(--spacing-sm);
opacity: 0.3;
font-size: var(--font-size-xs);
}
.users-table th[data-sort-key].sort-asc::after {
content: '▲';
opacity: 0.8;
}
.users-table th[data-sort-key].sort-desc::after {
content: '▼';
opacity: 0.8;
}
</style>
{% endblock %}
@ -1147,13 +1177,13 @@
<table class="users-table">
<thead>
<tr>
<th>ID</th>
<th>Użytkownik</th>
<th>Firma</th>
<th data-sort-key="id" data-sort-type="number">ID</th>
<th data-sort-key="name" data-sort-type="string">Użytkownik</th>
<th data-sort-key="company" data-sort-type="string">Firma</th>
<th>Upr. firmowe</th>
<th>Rola</th>
<th>Utworzono</th>
<th>Ostatnie logowanie</th>
<th data-sort-key="created" data-sort-type="date" class="sort-desc">Utworzono</th>
<th data-sort-key="last_login" data-sort-type="date">Ostatnie logowanie</th>
<th>Status</th>
<th>Akcje</th>
</tr>
@ -1208,10 +1238,12 @@
<option value="ADMIN" {% if user.role == 'ADMIN' %}selected{% endif %}>Administrator</option>
</select>
</td>
<td style="font-size: var(--font-size-sm); color: var(--text-secondary);">
<td style="font-size: var(--font-size-sm); color: var(--text-secondary);"
data-sort-value="{{ user.created_at.strftime('%Y%m%d%H%M') }}">
{{ user.created_at.strftime('%d.%m.%Y %H:%M') }}
</td>
<td style="font-size: var(--font-size-sm); color: var(--text-secondary);">
<td style="font-size: var(--font-size-sm); color: var(--text-secondary);"
data-sort-value="{{ user.last_login.strftime('%Y%m%d%H%M') if user.last_login else '0' }}">
{% if user.last_login %}
{{ user.last_login.strftime('%d.%m.%Y %H:%M') }}
{% else %}
@ -2875,4 +2907,77 @@ Lub format CSV, Excel, lista emaili..."></textarea>
showToast('Nie udało się skopiować', 'error');
});
}
// Column sorting
(function() {
const table = document.querySelector('.users-table');
if (!table) return;
const tbody = table.querySelector('tbody');
const headers = table.querySelectorAll('th[data-sort-key]');
// Column index map
const colIndex = {};
table.querySelectorAll('thead th').forEach((th, i) => {
if (th.dataset.sortKey) colIndex[th.dataset.sortKey] = i;
});
function getSortValue(row, key, type) {
const cell = row.children[colIndex[key]];
if (type === 'date') {
return cell.dataset.sortValue || '0';
}
if (type === 'number') {
return parseFloat(cell.textContent.trim()) || 0;
}
// string — for 'name' use .user-name, for 'company' use link or dash
if (key === 'name') {
const el = cell.querySelector('.user-name');
return (el ? el.textContent : cell.textContent).trim().toLowerCase();
}
if (key === 'company') {
const el = cell.querySelector('.user-company');
return el ? el.textContent.trim().toLowerCase() : 'zzz'; // push empty to end
}
return cell.textContent.trim().toLowerCase();
}
function sortTable(key, type, direction) {
const rows = Array.from(tbody.querySelectorAll('tr[data-user-id]'));
rows.sort((a, b) => {
const va = getSortValue(a, key, type);
const vb = getSortValue(b, key, type);
let cmp;
if (type === 'number') {
cmp = va - vb;
} else {
cmp = va < vb ? -1 : va > vb ? 1 : 0;
}
return direction === 'asc' ? cmp : -cmp;
});
rows.forEach(row => tbody.appendChild(row));
}
headers.forEach(th => {
th.addEventListener('click', function() {
const key = this.dataset.sortKey;
const type = this.dataset.sortType;
// Toggle direction
let dir;
if (this.classList.contains('sort-asc')) {
dir = 'desc';
} else if (this.classList.contains('sort-desc')) {
dir = 'asc';
} else {
dir = (type === 'date' || type === 'number') ? 'desc' : 'asc';
}
// Clear all sort indicators
headers.forEach(h => h.classList.remove('sort-asc', 'sort-desc'));
this.classList.add('sort-' + dir);
sortTable(key, type, dir);
});
});
})();
{% endblock %}