356 lines
13 KiB
Python
356 lines
13 KiB
Python
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
|
|
from blog.models import Post
|
|
from progression.models import Progression
|
|
import json
|
|
from django.http import JsonResponse
|
|
|
|
|
|
# --------------------
|
|
# Helpers Stats Module
|
|
# --------------------
|
|
|
|
def _parse_period(request, default=30, options=None):
|
|
"""Parse period parameter 'p' from request and compute date range.
|
|
|
|
Returns (p, now_dt, start_dt, start_date, end_date)
|
|
- now_dt is timezone-aware now
|
|
- start_dt is datetime at start of range (inclusive)
|
|
- start_date/end_date are date objects for convenient filtering
|
|
"""
|
|
if options is None:
|
|
options = [7, 30, 90, 180]
|
|
try:
|
|
p = int(request.GET.get('p', default))
|
|
except (TypeError, ValueError):
|
|
p = default
|
|
if p not in options:
|
|
p = default
|
|
now_dt = timezone.now()
|
|
start_dt = now_dt - timezone.timedelta(days=p - 1)
|
|
return p, now_dt, start_dt, start_dt.date(), now_dt.date()
|
|
|
|
|
|
def _build_series_for_range(start_date, end_date, qs, date_key='day', count_key='c'):
|
|
"""Build a continuous daily series from an aggregated queryset.
|
|
|
|
qs must yield dicts with date_key (date as string or date) and count_key.
|
|
Returns (labels_days, values_counts)
|
|
"""
|
|
counts = {str(item[date_key]): item[count_key] for item in qs}
|
|
days = []
|
|
values = []
|
|
d = start_date
|
|
while d <= end_date:
|
|
key = str(d)
|
|
days.append(key)
|
|
values.append(counts.get(key, 0))
|
|
d += timezone.timedelta(days=1)
|
|
return days, values
|
|
|
|
def home(request):
|
|
courses = Course.objects.order_by('-created_at')[:6]
|
|
return render(request, 'home.html', {'courses': courses})
|
|
|
|
def premium(request, course_id):
|
|
"""Landing page présentant les avantages du Premium."""
|
|
course = get_object_or_404(Course, pk=course_id)
|
|
return render(request, 'premium.html', {'course': course})
|
|
|
|
|
|
# 15 minutes de cache sur la page complète (sauf si décochée plus tard pour des blocs en direct)
|
|
@user_passes_test(lambda u: u.is_superuser)
|
|
@cache_page(60 * 15)
|
|
def stats_dashboard(request):
|
|
""" Tableau de bord statistiques réservé aux superadministrateurs.
|
|
Périodes supportées: 7, 30, 90, 180 jours (GET param 'p'). """
|
|
|
|
# Période
|
|
period_options = [7, 30, 90, 180]
|
|
p, now, start_dt, period_start_date, period_end_date = _parse_period(
|
|
request, default=30, options=period_options
|
|
)
|
|
|
|
# Utilisateurs
|
|
total_users = User.objects.count()
|
|
new_users_qs = User.objects.filter(date_joined__date__gte=period_start_date, date_joined__date__lte=period_end_date)
|
|
# Séries quotidiennes nouveaux utilisateurs
|
|
new_users_by_day = (
|
|
new_users_qs
|
|
.extra(select={'day': "date(date_joined)"})
|
|
.values('day')
|
|
.annotate(c=Count('id'))
|
|
)
|
|
|
|
# Activité approximée via Progression mise à jour
|
|
active_users_qs = (
|
|
Progression.objects.filter(updated_at__date__gte=period_start_date, updated_at__date__lte=period_end_date)
|
|
.values('user').distinct()
|
|
)
|
|
active_users_count = active_users_qs.count()
|
|
|
|
# Cours
|
|
total_courses = Course.objects.count()
|
|
total_courses_enabled = Course.objects.filter(enable=True).count()
|
|
new_courses_by_day = (
|
|
Course.objects.filter(created_at__date__gte=period_start_date, created_at__date__lte=period_end_date)
|
|
.extra(select={'day': "date(created_at)"})
|
|
.values('day').annotate(c=Count('id'))
|
|
)
|
|
|
|
# Leçons
|
|
total_lessons = Lesson.objects.count()
|
|
|
|
# Achèvements de leçons (via table de liaison M2M)
|
|
through = Progression.completed_lessons.through
|
|
lesson_completions_by_day = (
|
|
through.objects.filter(
|
|
progression__updated_at__date__gte=start_dt.date(),
|
|
progression__updated_at__date__lte=now.date(),
|
|
)
|
|
.extra(select={'day': "date(progression_updated_at)"}) if 'progression_updated_at' in [f.name for f in through._meta.fields]
|
|
else through.objects.extra(select={'day': "date(created_at)"}) # fallback si champs créé n'existe pas
|
|
)
|
|
# Si la table M2M n'a pas de timestamps, on utilisera updated_at de Progression pour l'activité par jour
|
|
# donc on refait une série quotidienne d'activité progression
|
|
progress_activity_by_day = (
|
|
Progression.objects.filter(updated_at__date__gte=period_start_date, updated_at__date__lte=period_end_date)
|
|
.extra(select={'day': "date(updated_at)"})
|
|
.values('day').annotate(c=Count('id'))
|
|
)
|
|
|
|
# Blog
|
|
total_posts = Post.objects.count()
|
|
new_posts_by_day = (
|
|
Post.objects.filter(created_at__date__gte=period_start_date, created_at__date__lte=period_end_date)
|
|
.extra(select={'day': "date(created_at)"})
|
|
.values('day').annotate(c=Count('id'))
|
|
)
|
|
|
|
# Revenus/Paiements & Technique (placeholders faute de sources)
|
|
revenus_disponibles = False
|
|
technique_disponible = False
|
|
|
|
# Visites / Trafic
|
|
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'):
|
|
# Wrapper conservant l'API locale mais utilisant le helper commun
|
|
return _build_series_for_range(period_start_date, period_end_date, qs, date_key=date_key, count_key=count_key)
|
|
|
|
days_users, values_new_users = build_series_dict(new_users_by_day)
|
|
days_courses, values_new_courses = build_series_dict(new_courses_by_day)
|
|
days_posts, values_new_posts = build_series_dict(new_posts_by_day)
|
|
days_activity, values_activity = build_series_dict(progress_activity_by_day)
|
|
|
|
# Tables simples (jour, valeur)
|
|
new_users_table = list(zip(days_users, values_new_users))
|
|
new_courses_table = list(zip(days_courses, values_new_courses))
|
|
|
|
context = {
|
|
'period_options': period_options,
|
|
'p': p,
|
|
'start_date': period_start_date,
|
|
'end_date': period_end_date,
|
|
|
|
# KPI
|
|
'kpi': {
|
|
'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,
|
|
'total_posts': total_posts,
|
|
},
|
|
|
|
# Séries pour graphiques
|
|
'series': {
|
|
'days': days_users, # mêmes intervalles pour tous
|
|
'new_users': values_new_users,
|
|
'new_courses': values_new_courses,
|
|
'new_posts': values_new_posts,
|
|
'activity_progress': values_activity,
|
|
},
|
|
|
|
# Disponibilité des sections
|
|
'revenus_disponibles': revenus_disponibles,
|
|
'technique_disponible': technique_disponible,
|
|
|
|
# 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
|
|
context['series_json'] = json.dumps(context['series'])
|
|
context['labels_json'] = json.dumps(context['series']['days'])
|
|
|
|
return render(request, 'home/stats_dashboard.html', context)
|
|
|
|
|
|
@user_passes_test(lambda u: u.is_superuser)
|
|
@cache_page(60 * 15)
|
|
def stats_charts(request):
|
|
"""Page dédiée aux graphiques (réservée superadmins)."""
|
|
# Période (utilise les mêmes helpers que le dashboard pour harmonisation)
|
|
period_options = [7, 30, 90, 180]
|
|
p, _now, _start_dt, period_start_date, period_end_date = _parse_period(
|
|
request, default=30, options=period_options
|
|
)
|
|
|
|
# Trafic par jour (visiteurs uniques)
|
|
visits_qs = (
|
|
Visit.objects
|
|
.filter(date__gte=period_start_date, date__lte=period_end_date)
|
|
.values('date')
|
|
.annotate(c=Count('visitor_id', distinct=True))
|
|
.order_by('date')
|
|
)
|
|
|
|
# Conversions par jour (visiteurs devenus utilisateurs)
|
|
conversions_qs = (
|
|
Visit.objects
|
|
.filter(
|
|
became_user_at__isnull=False,
|
|
became_user_at__date__gte=period_start_date,
|
|
became_user_at__date__lte=period_end_date,
|
|
)
|
|
.extra(select={'day': "date(became_user_at)"})
|
|
.values('day')
|
|
.annotate(c=Count('visitor_id', distinct=True))
|
|
.order_by('day')
|
|
)
|
|
|
|
def build_series_dict(qs, date_key='date', count_key='c'):
|
|
counts = {str(item[date_key]): item[count_key] for item in qs}
|
|
days = []
|
|
values = []
|
|
d = period_start_date
|
|
while d <= period_end_date:
|
|
key = str(d)
|
|
days.append(key)
|
|
values.append(counts.get(key, 0))
|
|
d += timezone.timedelta(days=1)
|
|
return days, values
|
|
|
|
labels, visitors_series = _build_series_for_range(period_start_date, period_end_date, visits_qs, date_key='date')
|
|
_, conversions_series = _build_series_for_range(period_start_date, period_end_date, conversions_qs, date_key='day')
|
|
|
|
# Sources & Pays (sur la période)
|
|
period_visits = Visit.objects.filter(date__gte=period_start_date, date__lte=period_end_date)
|
|
top_sources_qs = (
|
|
period_visits
|
|
.values('source')
|
|
.annotate(c=Count('visitor_id', distinct=True))
|
|
.order_by('-c')[:10]
|
|
)
|
|
top_countries_qs = (
|
|
period_visits
|
|
.exclude(country='')
|
|
.values('country')
|
|
.annotate(c=Count('visitor_id', distinct=True))
|
|
.order_by('-c')[:10]
|
|
)
|
|
|
|
sources_labels = [(row['source'] or 'Direct/Unknown') for row in top_sources_qs]
|
|
sources_values = [row['c'] for row in top_sources_qs]
|
|
countries_labels = [row['country'] for row in top_countries_qs]
|
|
countries_values = [row['c'] for row in top_countries_qs]
|
|
|
|
context = {
|
|
'period_options': period_options,
|
|
'p': p,
|
|
'start_date': period_start_date,
|
|
'end_date': period_end_date,
|
|
'labels_json': json.dumps(labels),
|
|
'visitors_series_json': json.dumps(visitors_series),
|
|
'conversions_series_json': json.dumps(conversions_series),
|
|
'sources_labels_json': json.dumps(sources_labels),
|
|
'sources_values_json': json.dumps(sources_values),
|
|
'countries_labels_json': json.dumps(countries_labels),
|
|
'countries_values_json': json.dumps(countries_values),
|
|
}
|
|
|
|
return render(request, 'home/stats_charts.html', context)
|
|
|
|
|
|
@user_passes_test(lambda u: u.is_superuser)
|
|
def live_activity(request):
|
|
"""Retourne en JSON l'activité récente (5 dernières minutes):
|
|
visiteurs et utilisateurs et leur page actuelle.
|
|
"""
|
|
now = timezone.now()
|
|
since = now - timezone.timedelta(minutes=5)
|
|
qs = (
|
|
Visit.objects
|
|
.filter(last_seen__gte=since)
|
|
.order_by('-last_seen')
|
|
)
|
|
|
|
data = []
|
|
for v in qs[:200]:
|
|
username = None
|
|
is_user = False
|
|
if v.user_id:
|
|
is_user = True
|
|
# safe access if user deleted
|
|
try:
|
|
username = v.user.username
|
|
except Exception:
|
|
username = 'Utilisateur'
|
|
visitor_label = v.visitor_id[:8]
|
|
seconds_ago = int((now - v.last_seen).total_seconds())
|
|
data.append({
|
|
'visitor': visitor_label,
|
|
'is_user': is_user,
|
|
'username': username,
|
|
'path': v.path,
|
|
'last_seen': v.last_seen.isoformat(),
|
|
'seconds_ago': seconds_ago,
|
|
'date': str(v.date),
|
|
'country': v.country,
|
|
'source': v.source,
|
|
})
|
|
|
|
return JsonResponse({'now': now.isoformat(), 'items': data})
|