loko/streetup/analytics/management/commands/cleanup_analytics.py
2026-07-22 14:48:40 +02:00

204 lines
7.5 KiB
Python

"""
Commande Django pour agréger les statistiques de pages vues
et purger les données détaillées de plus d'un mois.
Workflow:
1. Pour chaque jour ayant des PageView de plus de 30 jours,
calculer les agrégats et créer/mettre à jour un DailyPageViewSummary.
2. Supprimer les PageView et UserSession de plus de 30 jours.
Usage:
python manage.py cleanup_analytics
python manage.py cleanup_analytics --days 60
python manage.py cleanup_analytics --dry-run
"""
from datetime import timedelta
from django.core.management.base import BaseCommand
from django.db.models import Count, Avg, Max, Q
from django.db.models.functions import TruncDate
from django.utils import timezone
from analytics.models import PageView, UserSession, DailyPageViewSummary
class Command(BaseCommand):
help = (
"Agrège les statistiques quotidiennes de pages vues dans "
"DailyPageViewSummary puis purge les enregistrements détaillés "
"de PageView et UserSession de plus de N jours (défaut: 30)."
)
def add_arguments(self, parser):
parser.add_argument(
"--days",
type=int,
default=30,
help="Nombre de jours de rétention des données détaillées (défaut: 30).",
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Affiche ce qui serait fait sans modifier la base de données.",
)
def handle(self, *args, **options):
retention_days = options["days"]
dry_run = options["dry_run"]
cutoff = timezone.now() - timedelta(days=retention_days)
cutoff_date = cutoff.date()
if dry_run:
self.stdout.write(self.style.WARNING("[DRY-RUN] Aucune modification ne sera effectuée.\n"))
self.stdout.write(
self.style.WARNING(
f"\n=== Nettoyage analytics — rétention {retention_days} jours "
f"(avant le {cutoff_date}) ===\n"
)
)
# ── 1. Agrégation ─────────────────────────────────────────────
old_page_views = PageView.objects.filter(timestamp__lt=cutoff)
total_old = old_page_views.count()
if total_old == 0:
self.stdout.write("Aucune PageView à agréger.\n")
else:
self.stdout.write(f"{total_old} PageView(s) à agréger et purger.\n")
self._aggregate_page_views(old_page_views, dry_run)
# ── 2. Purge des PageView ─────────────────────────────────────
if not dry_run and total_old > 0:
deleted_pv, _ = old_page_views.delete()
self.stdout.write(self.style.SUCCESS(f" ✓ {deleted_pv} PageView(s) supprimée(s).\n"))
# ── 3. Purge des UserSession inactives ────────────────────────
old_sessions = UserSession.objects.filter(
last_activity__lt=cutoff,
is_active=False,
)
total_sessions = old_sessions.count()
if total_sessions == 0:
self.stdout.write("Aucune UserSession inactive à purger.\n")
else:
if dry_run:
self.stdout.write(f" [DRY-RUN] {total_sessions} UserSession(s) inactives seraient supprimées.\n")
else:
deleted_us, _ = old_sessions.delete()
self.stdout.write(self.style.SUCCESS(f" ✓ {deleted_us} UserSession(s) inactives supprimées.\n"))
self.stdout.write(self.style.SUCCESS("\n=== Nettoyage terminé ===\n"))
# ──────────────────────────────────────────────────────────────────
# Méthodes internes
# ──────────────────────────────────────────────────────────────────
def _aggregate_page_views(self, queryset, dry_run):
"""
Calcule les résumés quotidiens à partir du queryset fourni
et crée / met à jour les DailyPageViewSummary correspondants.
"""
days = (
queryset
.annotate(day=TruncDate("timestamp"))
.values("day")
.annotate(count=Count("id"))
.order_by("day")
)
for entry in days:
day = entry["day"]
day_qs = queryset.filter(timestamp__date=day)
summary_data = self._compute_daily_summary(day, day_qs)
if dry_run:
self.stdout.write(
f" [DRY-RUN] {day} : {summary_data['total_page_views']} vues, "
f"{summary_data['unique_users']} utilisateurs uniques"
)
else:
DailyPageViewSummary.objects.update_or_create(
date=day,
defaults=summary_data,
)
self.stdout.write(
self.style.SUCCESS(
f" ✓ Résumé {day} : {summary_data['total_page_views']} vues, "
f"{summary_data['unique_users']} utilisateurs uniques"
)
)
def _compute_daily_summary(self, day, day_qs):
"""Calcule les métriques agrégées pour un jour donné."""
# Compteurs de base
total_page_views = day_qs.count()
unique_users = (
day_qs.filter(user__isnull=False)
.values("user")
.distinct()
.count()
)
unique_sessions = (
day_qs.exclude(session_key="")
.values("session_key")
.distinct()
.count()
)
unique_ips = (
day_qs.filter(ip_address__isnull=False)
.values("ip_address")
.distinct()
.count()
)
# Temps de réponse
response_stats = day_qs.filter(
response_time_ms__isnull=False
).aggregate(
avg=Avg("response_time_ms"),
mx=Max("response_time_ms"),
)
avg_response = response_stats["avg"]
max_response = response_stats["mx"]
# Top 20 pages
top_paths = dict(
day_qs.values_list("path")
.annotate(cnt=Count("id"))
.order_by("-cnt")[:20]
)
# Top 20 utilisateurs
top_users = dict(
day_qs.filter(user__isnull=False)
.values_list("user__username")
.annotate(cnt=Count("id"))
.order_by("-cnt")[:20]
)
# Répartition par méthode HTTP
method_counts = dict(
day_qs.values_list("method")
.annotate(cnt=Count("id"))
.order_by("-cnt")
)
# Sessions démarrées ce jour-là
total_sessions_started = UserSession.objects.filter(
login_time__date=day
).count()
return {
"total_page_views": total_page_views,
"unique_users": unique_users,
"unique_sessions": unique_sessions,
"unique_ips": unique_ips,
"avg_response_time_ms": round(avg_response, 2) if avg_response else None,
"max_response_time_ms": max_response,
"top_paths": top_paths,
"top_users": top_users,
"method_counts": method_counts,
"total_sessions_started": total_sessions_started,
}