diff --git a/core/admin.py b/core/admin.py index 0989d6d..a9baff2 100644 --- a/core/admin.py +++ b/core/admin.py @@ -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") diff --git a/core/middleware.py b/core/middleware.py new file mode 100644 index 0000000..3e76796 --- /dev/null +++ b/core/middleware.py @@ -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']) diff --git a/core/migrations/0004_visit.py b/core/migrations/0004_visit.py new file mode 100644 index 0000000..6301e48 --- /dev/null +++ b/core/migrations/0004_visit.py @@ -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')}, + ), + ] diff --git a/core/models.py b/core/models.py index b95ca68..63d9b18 100644 --- a/core/models.py +++ b/core/models.py @@ -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" \ No newline at end of file + 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}" \ No newline at end of file diff --git a/devart/settings.py b/devart/settings.py index a352b3f..d5b3597 100644 --- a/devart/settings.py +++ b/devart/settings.py @@ -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' \ No newline at end of file +EMAIL_BACKEND = dotenv.get_key('.env', 'EMAIL_BACKEND') +DEFAULT_FROM_EMAIL = dotenv.get_key('.env', 'EMAIL_HOST_USER') \ No newline at end of file diff --git a/home/views.py b/home/views.py index 9702e82..159fb3f 100644 --- a/home/views.py +++ b/home/views.py @@ -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 diff --git a/templates/home/stats_dashboard.html b/templates/home/stats_dashboard.html index e5daaed..84a7094 100644 --- a/templates/home/stats_dashboard.html +++ b/templates/home/stats_dashboard.html @@ -35,6 +35,18 @@
+
+

Visiteurs uniques (période)

+
{{ kpi.unique_visitors }}
+
+
+

Visiteurs revenants (période)

+
{{ kpi.returning_visitors }}
+
+
+

Conversions en utilisateurs (période)

+
{{ kpi.converted_visitors }}
+

Utilisateurs (total)

{{ kpi.total_users }}
@@ -108,6 +120,35 @@
+ +
+
+

Top sources (visiteurs uniques)

+ + + + {% for row in top_sources_table %} + + {% empty %} + + {% endfor %} + +
SourceVisiteurs
{{ row.0 }}{{ row.1 }}
Aucune donnée
+
+
+

Top pays (visiteurs uniques)

+ + + + {% for row in top_countries_table %} + + {% empty %} + + {% endfor %} + +
PaysVisiteurs
{{ row.0 }}{{ row.1 }}
Aucune donnée
+
+