Ajout de la fonctionnalité d’activité en direct dans le tableau de bord des statistiques et mise à jour des templates, vues, URL, et styles associés.

This commit is contained in:
mrtoine 2025-12-16 14:15:58 +01:00
parent a7b51e3a82
commit 7cf04968eb
6 changed files with 114 additions and 2 deletions

View file

@ -111,7 +111,11 @@ class VisitTrackingMiddleware:
# Marquer la conversion si pas encore définie # Marquer la conversion si pas encore définie
if visit.became_user_at is None: if visit.became_user_at is None:
visit.became_user_at = now visit.became_user_at = now
# Mettre à jour la page courante et l'horodatage
if visit.path != path:
visit.path = path[:512]
dirty = True
visit.last_seen = now visit.last_seen = now
dirty = True dirty = True
if dirty: if dirty:
visit.save(update_fields=['source', 'country', 'user', 'became_user_at', 'last_seen']) visit.save(update_fields=['source', 'country', 'user', 'became_user_at', 'path', 'last_seen'])

View file

@ -8,4 +8,5 @@ urlpatterns = [
# Tableau de bord statistiques (réservé superadministrateurs) # Tableau de bord statistiques (réservé superadministrateurs)
path('dashboard/stats/', views.stats_dashboard, name='stats_dashboard'), path('dashboard/stats/', views.stats_dashboard, name='stats_dashboard'),
path('dashboard/stats/charts/', views.stats_charts, name='stats_charts'), path('dashboard/stats/charts/', views.stats_charts, name='stats_charts'),
path('dashboard/stats/live-activity/', views.live_activity, name='live_activity'),
] ]

View file

@ -9,6 +9,7 @@ from courses.models import Course, Lesson
from blog.models import Post from blog.models import Post
from progression.models import Progression from progression.models import Progression
import json import json
from django.http import JsonResponse
def home(request): def home(request):
courses = Course.objects.order_by('-created_at')[:6] courses = Course.objects.order_by('-created_at')[:6]
@ -293,3 +294,44 @@ def stats_charts(request):
} }
return render(request, 'home/stats_charts.html', context) return render(request, 'home/stats_charts.html', context)
@user_passes_test(lambda u: u.is_superuser)
def live_activity(request):
"""Retourne en JSON l'activité récente (5 dernières minutes):
visiteurs et utilisateurs et leur page actuelle.
"""
now = timezone.now()
since = now - timezone.timedelta(minutes=5)
qs = (
Visit.objects
.filter(last_seen__gte=since)
.order_by('-last_seen')
)
data = []
for v in qs[:200]:
username = None
is_user = False
if v.user_id:
is_user = True
# safe access if user deleted
try:
username = v.user.username
except Exception:
username = 'Utilisateur'
visitor_label = v.visitor_id[:8]
seconds_ago = int((now - v.last_seen).total_seconds())
data.append({
'visitor': visitor_label,
'is_user': is_user,
'username': username,
'path': v.path,
'last_seen': v.last_seen.isoformat(),
'seconds_ago': seconds_ago,
'date': str(v.date),
'country': v.country,
'source': v.source,
})
return JsonResponse({'now': now.isoformat(), 'items': data})

View file

@ -2330,6 +2330,16 @@ input[type="text"], input[type="email"], input[type="password"], textarea {
filter: brightness(1.05); filter: brightness(1.05);
} }
.btn-warning, .button-warning {
background-color: var(--warning);
border-color: var(--warning);
color: var(--warning-contrast);
}
.btn-warning:hover, .button-warning:hover {
filter: brightness(1.05);
}
/* Tailles */ /* Tailles */
.btn-sm, .button-sm { padding: 6px 10px; font-size: 0.9rem; } .btn-sm, .button-sm { padding: 6px 10px; font-size: 0.9rem; }
.btn-lg, .button-lg { padding: 12px 20px; font-size: 1.05rem; } .btn-lg, .button-lg { padding: 12px 20px; font-size: 1.05rem; }

View file

@ -20,7 +20,9 @@
{% block content %} {% block content %}
<div class="container"> <div class="container">
<h1>Tableau de bord statistiques</h1> <h1>Tableau de bord statistiques</h1>
<p style="margin:8px 0"><a href="{% url 'home:stats_charts' %}">→ Voir la page de graphiques</a></p> <p style="margin:8px 0">
<a href="{% url 'home:stats_charts' %}">→ Voir la page de graphiques</a>
</p>
<form method="get" class="toolbar"> <form method="get" class="toolbar">
<div> <div>
@ -90,6 +92,20 @@
</div> </div>
</section> </section>
<section class="tables" id="live-activity">
<div class="card" style="grid-column: 1 / -1;">
<h3>Activité en direct (5 dernières minutes)</h3>
<table id="live-activity-table" style="width:100%">
<thead>
<tr><th>Type</th><th>Identité</th><th>Page</th><th>Il y a</th><th>Source</th><th>Pays</th></tr>
</thead>
<tbody>
<tr id="live-empty"><td colspan="6">Chargement…</td></tr>
</tbody>
</table>
</div>
</section>
<section class="charts"> <section class="charts">
<div class="card"> <div class="card">
<h3>Évolution quotidienne</h3> <h3>Évolution quotidienne</h3>
@ -173,5 +189,43 @@
plugins: {legend: {position: 'bottom'}} plugins: {legend: {position: 'bottom'}}
} }
}); });
// Live activity polling
function humanizeSeconds(s) {
if (s < 60) return s + 's';
const m = Math.floor(s/60); const r = s % 60;
if (m < 60) return m + 'm' + (r?(' '+r+'s'):'');
const h = Math.floor(m/60); const mr = m % 60; return h + 'h' + (mr?(' '+mr+'m'):'');
}
async function fetchLive() {
try {
const res = await fetch('{% url "home:live_activity" %}', {headers: {'Accept': 'application/json'}});
if (!res.ok) throw new Error('HTTP '+res.status);
const payload = await res.json();
const tbody = document.querySelector('#live-activity-table tbody');
tbody.innerHTML = '';
const items = payload.items || [];
if (items.length === 0) {
const tr = document.createElement('tr');
const td = document.createElement('td'); td.colSpan = 6; td.textContent = 'Aucune activité récente';
tr.appendChild(td); tbody.appendChild(tr); return;
}
for (const it of items) {
const tr = document.createElement('tr');
const type = document.createElement('td'); type.textContent = it.is_user ? 'Utilisateur' : 'Visiteur';
const ident = document.createElement('td'); ident.textContent = it.is_user ? (it.username || 'Utilisateur') : it.visitor;
const page = document.createElement('td'); page.textContent = it.path || '/';
const ago = document.createElement('td'); ago.textContent = humanizeSeconds(it.seconds_ago);
const src = document.createElement('td'); src.textContent = it.source || '';
const country = document.createElement('td'); country.textContent = it.country || '';
tr.append(type, ident, page, ago, src, country);
tbody.appendChild(tr);
}
} catch (e) {
// silent fail in UI
}
}
fetchLive();
setInterval(fetchLive, 10000);
</script> </script>
{% endblock %} {% endblock %}

View file

@ -48,6 +48,7 @@
{% if user.is_authenticated and user.is_staff %} {% if user.is_authenticated and user.is_staff %}
<li> <li>
<a href="{% url 'admin:index' %}" class="button button-danger" title="Administration">Admin</a> <a href="{% url 'admin:index' %}" class="button button-danger" title="Administration">Admin</a>
<a href="{% url 'home:stats_dashboard' %}" class="button button-warning" title="Stats">Stats</a>
</li> </li>
{% endif %} {% endif %}
<li> <li>