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}"
|
||||
|
|
@ -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')
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue