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:
parent
bec74976ba
commit
6e8a2bc287
7 changed files with 286 additions and 5 deletions
|
|
@ -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
117
core/middleware.py
Normal 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'])
|
||||
41
core/migrations/0004_visit.py
Normal file
41
core/migrations/0004_visit.py
Normal 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')},
|
||||
),
|
||||
]
|
||||
|
|
@ -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")
|
||||
|
|
@ -30,4 +32,36 @@ class SiteSettings(models.Model):
|
|||
|
||||
class Meta:
|
||||
verbose_name = "Réglages du site"
|
||||
verbose_name_plural = "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}"
|
||||
Loading…
Add table
Add a link
Reference in a new issue