Ajout du suivi des visites : modèle Visit, middleware de tracking, mises à jour des vues et du tableau de bord statistiques.

This commit is contained in:
mrtoine 2025-12-16 10:28:20 +01:00
parent bec74976ba
commit 6e8a2bc287
7 changed files with 286 additions and 5 deletions

View file

@ -1,5 +1,5 @@
from django.contrib import admin
from .models import SiteSettings
from .models import SiteSettings, Visit
@admin.register(SiteSettings)
class SiteSettingsAdmin(admin.ModelAdmin):
@ -27,3 +27,10 @@ class SiteSettingsAdmin(admin.ModelAdmin):
'fields': ('blog_title', 'blog_description')
}),
)
@admin.register(Visit)
class VisitAdmin(admin.ModelAdmin):
list_display = ("date", "visitor_id", "user", "source", "country", "first_seen", "last_seen")
list_filter = ("date", "country", "source")
search_fields = ("visitor_id", "referrer", "utm_source", "utm_medium", "utm_campaign")

117
core/middleware.py Normal file
View file

@ -0,0 +1,117 @@
import uuid
from urllib.parse import urlparse
from django.utils import timezone
from .models import Visit
class VisitTrackingMiddleware:
"""Middleware très léger pour enregistrer des statistiques de visites.
- Assigne un cookie visiteur persistant (vid) si absent
- Enregistre/Met à jour une ligne Visit par visiteur et par jour
- Capture la source (UTM/referrer) et le pays si disponible via headers
"""
COOKIE_NAME = 'vid'
COOKIE_MAX_AGE = 60 * 60 * 24 * 365 * 2 # 2 ans
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
vid = request.COOKIES.get(self.COOKIE_NAME)
if not vid:
vid = uuid.uuid4().hex
request.visitor_id = vid
# Enregistrer la visite (agrégée par jour)
try:
self._track(request, vid)
except Exception:
# On ne casse jamais la requête pour des stats
pass
response = self.get_response(request)
# S'assurer que le cookie est posé
if request.COOKIES.get(self.COOKIE_NAME) != vid:
response.set_cookie(
self.COOKIE_NAME,
vid,
max_age=self.COOKIE_MAX_AGE,
httponly=True,
samesite='Lax',
)
return response
def _track(self, request, vid):
# On ignore l'admin et les assets statiques
path = request.path
if path.startswith('/admin') or path.startswith('/static') or path.startswith('/staticfiles'):
return
now = timezone.now()
date = now.date()
ref = request.META.get('HTTP_REFERER', '')[:512]
utm_source = request.GET.get('utm_source', '')[:100]
utm_medium = request.GET.get('utm_medium', '')[:100]
utm_campaign = request.GET.get('utm_campaign', '')[:150]
# Déterminer source
source = ''
if utm_source:
source = utm_source
elif ref:
try:
netloc = urlparse(ref).netloc
source = netloc
except Exception:
source = ref[:150]
# Déterminer pays via en-têtes si fournis par proxy/CDN
country = (
request.META.get('HTTP_CF_IPCOUNTRY')
or request.META.get('HTTP_X_APPENGINE_COUNTRY')
or request.META.get('HTTP_X_COUNTRY')
or ''
)[:64]
became_user_at = now if request.user.is_authenticated else None
visit, created = Visit.objects.get_or_create(
visitor_id=vid,
date=date,
defaults={
'path': path[:512],
'referrer': ref,
'utm_source': utm_source,
'utm_medium': utm_medium,
'utm_campaign': utm_campaign,
'source': source,
'country': country,
'first_seen': now,
'last_seen': now,
'user': request.user if request.user.is_authenticated else None,
'became_user_at': became_user_at,
}
)
if not created:
# Mise à jour basique
dirty = False
if not visit.source and source:
visit.source = source
dirty = True
if not visit.country and country:
visit.country = country
dirty = True
if request.user.is_authenticated and visit.user_id is None:
visit.user = request.user
dirty = True
# Marquer la conversion si pas encore définie
if visit.became_user_at is None:
visit.became_user_at = now
visit.last_seen = now
dirty = True
if dirty:
visit.save(update_fields=['source', 'country', 'user', 'became_user_at', 'last_seen'])

View file

@ -0,0 +1,41 @@
from django.db import migrations, models
import django.db.models.deletion
from django.conf import settings
import django.utils.timezone
class Migration(migrations.Migration):
dependencies = [
('core', '0003_alter_sitesettings_blog_description_and_more'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Visit',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('visitor_id', models.CharField(db_index=True, max_length=64)),
('date', models.DateField(db_index=True)),
('first_seen', models.DateTimeField(default=django.utils.timezone.now)),
('last_seen', models.DateTimeField(default=django.utils.timezone.now)),
('path', models.CharField(blank=True, max_length=512)),
('referrer', models.CharField(blank=True, max_length=512)),
('utm_source', models.CharField(blank=True, max_length=100)),
('utm_medium', models.CharField(blank=True, max_length=100)),
('utm_campaign', models.CharField(blank=True, max_length=150)),
('source', models.CharField(blank=True, help_text='Domaine de provenance ou utm_source', max_length=150)),
('country', models.CharField(blank=True, max_length=64)),
('became_user_at', models.DateTimeField(blank=True, null=True)),
('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='visits', to=settings.AUTH_USER_MODEL)),
],
options={
'ordering': ['-date', '-last_seen'],
},
),
migrations.AlterUniqueTogether(
name='visit',
unique_together={('visitor_id', 'date')},
),
]

View file

@ -1,4 +1,6 @@
from django.db import models
from django.conf import settings
from django.utils import timezone
class SiteSettings(models.Model):
site_name = models.CharField(max_length=200, default="Mon Super Site")
@ -31,3 +33,35 @@ class SiteSettings(models.Model):
class Meta:
verbose_name = "Réglages du site"
verbose_name_plural = "Réglages du site"
class Visit(models.Model):
"""Enregistrement simplifié des visites (agrégées par jour et visiteur).
Objectif: fournir des stats de base sans dépendances externes.
"""
visitor_id = models.CharField(max_length=64, db_index=True)
user = models.ForeignKey(
settings.AUTH_USER_MODEL, null=True, blank=True, on_delete=models.SET_NULL, related_name='visits'
)
date = models.DateField(db_index=True)
first_seen = models.DateTimeField(default=timezone.now)
last_seen = models.DateTimeField(default=timezone.now)
path = models.CharField(max_length=512, blank=True)
referrer = models.CharField(max_length=512, blank=True)
utm_source = models.CharField(max_length=100, blank=True)
utm_medium = models.CharField(max_length=100, blank=True)
utm_campaign = models.CharField(max_length=150, blank=True)
source = models.CharField(max_length=150, blank=True, help_text="Domaine de provenance ou utm_source")
country = models.CharField(max_length=64, blank=True)
# Conversion: première fois où un visiteur devient utilisateur authentifié
became_user_at = models.DateTimeField(null=True, blank=True)
class Meta:
unique_together = ('visitor_id', 'date')
ordering = ['-date', '-last_seen']
def __str__(self):
return f"{self.visitor_id} @ {self.date}"

View file

@ -12,6 +12,8 @@ https://docs.djangoproject.com/en/5.0/ref/settings/
from pathlib import Path
import os
import dotenv
from dotenv import load_dotenv
import devart.context_processor
@ -64,7 +66,7 @@ MIDDLEWARE = [
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
'core.middleware.VisitTrackingMiddleware',
]
ROOT_URLCONF = 'devart.urls'
@ -204,5 +206,5 @@ def get_git_version():
GIT_VERSION = get_git_version()
EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend'
DEFAULT_FROM_EMAIL = 'noreply@partirdezero.local'
EMAIL_BACKEND = dotenv.get_key('.env', 'EMAIL_BACKEND')
DEFAULT_FROM_EMAIL = dotenv.get_key('.env', 'EMAIL_HOST_USER')

View file

@ -2,6 +2,7 @@ from django.shortcuts import render, get_object_or_404
from django.contrib.auth.decorators import user_passes_test
from django.utils import timezone
from django.db.models import Count
from core.models import Visit
from django.views.decorators.cache import cache_page
from django.contrib.auth.models import User
from courses.models import Course, Lesson
@ -98,6 +99,39 @@ def stats_dashboard(request):
revenus_disponibles = False
technique_disponible = False
# Visites / Trafic
period_start_date = start_dt.date()
period_end_date = now.date()
period_visits = Visit.objects.filter(date__gte=period_start_date, date__lte=period_end_date)
unique_visitors = period_visits.values('visitor_id').distinct().count()
earlier_visitors_qs = Visit.objects.filter(date__lt=period_start_date).values('visitor_id').distinct()
returning_visitors = period_visits.filter(visitor_id__in=earlier_visitors_qs).values('visitor_id').distinct().count()
converted_visitors = (
period_visits
.filter(became_user_at__isnull=False, became_user_at__date__gte=period_start_date, became_user_at__date__lte=period_end_date)
.values('visitor_id').distinct().count()
)
top_sources_qs = (
period_visits
.values('source')
.annotate(c=Count('visitor_id', distinct=True))
.order_by('-c')
)
top_countries_qs = (
period_visits
.exclude(country='')
.values('country')
.annotate(c=Count('visitor_id', distinct=True))
.order_by('-c')
)
top_sources_table = [(row['source'] or 'Direct/Unknown', row['c']) for row in top_sources_qs[:10]]
top_countries_table = [(row['country'], row['c']) for row in top_countries_qs[:10]]
# Helper pour avoir toutes les dates de la période et remplir les trous
def build_series_dict(qs, date_key='day', count_key='c'):
counts = {str(item[date_key]): item[count_key] for item in qs}
@ -131,6 +165,9 @@ def stats_dashboard(request):
'total_users': total_users,
'new_users_period': sum(values_new_users),
'active_users_period': active_users_count,
'unique_visitors': unique_visitors,
'returning_visitors': returning_visitors,
'converted_visitors': converted_visitors,
'total_courses': total_courses,
'courses_enabled': total_courses_enabled,
'total_lessons': total_lessons,
@ -153,6 +190,8 @@ def stats_dashboard(request):
# Tables
'new_users_table': new_users_table,
'new_courses_table': new_courses_table,
'top_sources_table': top_sources_table,
'top_countries_table': top_countries_table,
}
# Sérialisation JSON pour Chart.js

View file

@ -35,6 +35,18 @@
</form>
<section class="stats-grid">
<div class="card">
<h3>Visiteurs uniques (période)</h3>
<div class="kpi">{{ kpi.unique_visitors }}</div>
</div>
<div class="card">
<h3>Visiteurs revenants (période)</h3>
<div class="kpi">{{ kpi.returning_visitors }}</div>
</div>
<div class="card">
<h3>Conversions en utilisateurs (période)</h3>
<div class="kpi">{{ kpi.converted_visitors }}</div>
</div>
<div class="card">
<h3>Utilisateurs (total)</h3>
<div class="kpi">{{ kpi.total_users }}</div>
@ -108,6 +120,35 @@
</table>
</div>
</section>
<section class="tables">
<div class="card">
<h3>Top sources (visiteurs uniques)</h3>
<table>
<thead><tr><th>Source</th><th>Visiteurs</th></tr></thead>
<tbody>
{% for row in top_sources_table %}
<tr><td>{{ row.0 }}</td><td>{{ row.1 }}</td></tr>
{% empty %}
<tr><td colspan="2">Aucune donnée</td></tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="card">
<h3>Top pays (visiteurs uniques)</h3>
<table>
<thead><tr><th>Pays</th><th>Visiteurs</th></tr></thead>
<tbody>
{% for row in top_countries_table %}
<tr><td>{{ row.0 }}</td><td>{{ row.1 }}</td></tr>
{% empty %}
<tr><td colspan="2">Aucune donnée</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</section>
</div>
<script>