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 django.contrib import admin
|
||||||
from .models import SiteSettings
|
from .models import SiteSettings, Visit
|
||||||
|
|
||||||
@admin.register(SiteSettings)
|
@admin.register(SiteSettings)
|
||||||
class SiteSettingsAdmin(admin.ModelAdmin):
|
class SiteSettingsAdmin(admin.ModelAdmin):
|
||||||
|
|
@ -27,3 +27,10 @@ class SiteSettingsAdmin(admin.ModelAdmin):
|
||||||
'fields': ('blog_title', 'blog_description')
|
'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.db import models
|
||||||
|
from django.conf import settings
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
class SiteSettings(models.Model):
|
class SiteSettings(models.Model):
|
||||||
site_name = models.CharField(max_length=200, default="Mon Super Site")
|
site_name = models.CharField(max_length=200, default="Mon Super Site")
|
||||||
|
|
@ -30,4 +32,36 @@ class SiteSettings(models.Model):
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
verbose_name = "Réglages du site"
|
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
|
from pathlib import Path
|
||||||
import os
|
import os
|
||||||
|
|
||||||
|
import dotenv
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
import devart.context_processor
|
import devart.context_processor
|
||||||
|
|
@ -64,7 +66,7 @@ MIDDLEWARE = [
|
||||||
'django.contrib.messages.middleware.MessageMiddleware',
|
'django.contrib.messages.middleware.MessageMiddleware',
|
||||||
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
||||||
|
|
||||||
|
'core.middleware.VisitTrackingMiddleware',
|
||||||
]
|
]
|
||||||
|
|
||||||
ROOT_URLCONF = 'devart.urls'
|
ROOT_URLCONF = 'devart.urls'
|
||||||
|
|
@ -204,5 +206,5 @@ def get_git_version():
|
||||||
|
|
||||||
GIT_VERSION = get_git_version()
|
GIT_VERSION = get_git_version()
|
||||||
|
|
||||||
EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend'
|
EMAIL_BACKEND = dotenv.get_key('.env', 'EMAIL_BACKEND')
|
||||||
DEFAULT_FROM_EMAIL = 'noreply@partirdezero.local'
|
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.contrib.auth.decorators import user_passes_test
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.db.models import Count
|
from django.db.models import Count
|
||||||
|
from core.models import Visit
|
||||||
from django.views.decorators.cache import cache_page
|
from django.views.decorators.cache import cache_page
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
from courses.models import Course, Lesson
|
from courses.models import Course, Lesson
|
||||||
|
|
@ -98,6 +99,39 @@ def stats_dashboard(request):
|
||||||
revenus_disponibles = False
|
revenus_disponibles = False
|
||||||
technique_disponible = 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
|
# 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'):
|
def build_series_dict(qs, date_key='day', count_key='c'):
|
||||||
counts = {str(item[date_key]): item[count_key] for item in qs}
|
counts = {str(item[date_key]): item[count_key] for item in qs}
|
||||||
|
|
@ -131,6 +165,9 @@ def stats_dashboard(request):
|
||||||
'total_users': total_users,
|
'total_users': total_users,
|
||||||
'new_users_period': sum(values_new_users),
|
'new_users_period': sum(values_new_users),
|
||||||
'active_users_period': active_users_count,
|
'active_users_period': active_users_count,
|
||||||
|
'unique_visitors': unique_visitors,
|
||||||
|
'returning_visitors': returning_visitors,
|
||||||
|
'converted_visitors': converted_visitors,
|
||||||
'total_courses': total_courses,
|
'total_courses': total_courses,
|
||||||
'courses_enabled': total_courses_enabled,
|
'courses_enabled': total_courses_enabled,
|
||||||
'total_lessons': total_lessons,
|
'total_lessons': total_lessons,
|
||||||
|
|
@ -153,6 +190,8 @@ def stats_dashboard(request):
|
||||||
# Tables
|
# Tables
|
||||||
'new_users_table': new_users_table,
|
'new_users_table': new_users_table,
|
||||||
'new_courses_table': new_courses_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
|
# Sérialisation JSON pour Chart.js
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,18 @@
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<section class="stats-grid">
|
<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">
|
<div class="card">
|
||||||
<h3>Utilisateurs (total)</h3>
|
<h3>Utilisateurs (total)</h3>
|
||||||
<div class="kpi">{{ kpi.total_users }}</div>
|
<div class="kpi">{{ kpi.total_users }}</div>
|
||||||
|
|
@ -108,6 +120,35 @@
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</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>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue