From 609745a723af52072b2c69cc3e74199ccb4525d2 Mon Sep 17 00:00:00 2001 From: mrtoine Date: Mon, 15 Dec 2025 21:30:13 +0100 Subject: [PATCH 01/38] =?UTF-8?q?Mise=20=C3=A0=20jour=20de=20la=20version?= =?UTF-8?q?=20applicative=20dans=20`VERSION.txt`.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- VERSION.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/VERSION.txt b/VERSION.txt index b1479a2..68b8593 100644 --- a/VERSION.txt +++ b/VERSION.txt @@ -1 +1 @@ -1.2.0 (c174906) \ No newline at end of file +1.2.1 (4a48425) \ No newline at end of file From ac8ef6894daa85baa59f2110d13175d1da10f21c Mon Sep 17 00:00:00 2001 From: mrtoine Date: Mon, 15 Dec 2025 22:34:02 +0100 Subject: [PATCH 02/38] =?UTF-8?q?Ajout=20des=20fonctionnalit=C3=A9s=20de?= =?UTF-8?q?=20gestion=20de=20progression=20des=20cours=20:=20vue=20d=C3=A9?= =?UTF-8?q?di=C3=A9e=20pour=20le=20toggle=20des=20le=C3=A7ons,=20mise=20?= =?UTF-8?q?=C3=A0=20jour=20des=20templates=20pour=20afficher=20la=20progre?= =?UTF-8?q?ssion,=20int=C3=A9gration=20des=20routes=20Ajax,=20styles=20ass?= =?UTF-8?q?oci=C3=A9s,=20et=20ajustements=20des=20vues=20et=20mod=C3=A8les?= =?UTF-8?q?=20pour=20g=C3=A9rer=20les=20donn=C3=A9es=20utilisateur.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- courses/views.py | 29 +++++ devart/urls.py | 2 +- progression/urls.py | 5 +- progression/views.py | 42 +++++++- static/css/app.css | 102 +++++++++++++++++- .../courses/partials/_course_header.html | 19 ++++ templates/courses/partials/_course_toc.html | 67 +++++++++++- templates/layout.html | 4 +- 8 files changed, 261 insertions(+), 9 deletions(-) diff --git a/courses/views.py b/courses/views.py index 8a33bbb..ca9fb22 100644 --- a/courses/views.py +++ b/courses/views.py @@ -3,6 +3,7 @@ from django.urls import reverse from django.views import generic from django.db.models import Prefetch from .models import Course, Lesson, Module, Comment +from progression.models import Progression from .forms import CommentForm def list_courses(request): @@ -23,9 +24,23 @@ def show(request, course_name, course_id): .order_by('order') ) + # Récupération de la progression de l'utilisateur sur le cours + user_progress = None + completed_lesson_ids = [] # On prépare une liste vide par défaut + + if request.user.is_authenticated: + user_progress = Progression.objects.filter(user=request.user, course=course).first() + + # 2. S'il y a une progression, on extrait juste les IDs des leçons finies + if user_progress: + # values_list renvoie une liste simple : [1, 5, 12] au lieu d'objets lourds + completed_lesson_ids = user_progress.completed_lessons.values_list('id', flat=True) + context = { 'course': course, 'lessons': lessons, + 'user_progress': user_progress, + 'completed_lesson_ids': completed_lesson_ids, } return render(request, 'courses/show.html', context) @@ -105,6 +120,18 @@ def lesson_detail(request, course_slug, module_slug, lesson_slug): .order_by('created_at') ) + # Récupération de la progression de l'utilisateur sur le cours + user_progress = None + completed_lesson_ids = [] # On prépare une liste vide par défaut + + if request.user.is_authenticated: + user_progress = Progression.objects.filter(user=request.user, course=course).first() + + # 2. S'il y a une progression, on extrait juste les IDs des leçons finies + if user_progress: + # values_list renvoie une liste simple : [1, 5, 12] au lieu d'objets lourds + completed_lesson_ids = user_progress.completed_lessons.values_list('id', flat=True) + context = { 'course': course, 'module': module, @@ -114,5 +141,7 @@ def lesson_detail(request, course_slug, module_slug, lesson_slug): 'comment_form': form, 'prev_lesson': prev_lesson, 'next_lesson': next_lesson, + 'user_progress': user_progress, + 'completed_lesson_ids': completed_lesson_ids, } return render(request, 'courses/lesson.html', context) \ No newline at end of file diff --git a/devart/urls.py b/devart/urls.py index b46feda..17cb4e7 100644 --- a/devart/urls.py +++ b/devart/urls.py @@ -43,7 +43,7 @@ urlpatterns = [ path('', include('home.urls')), path('courses/', include('courses.urls')), path('users/', include('users.urls')), - + path('progression/', include('progression.urls')), path('blog/', include('blog.urls')), path('sitemap.xml', sitemap, {'sitemaps': sitemaps_dict}, name='django.contrib.sitemaps.views.sitemap'), diff --git a/progression/urls.py b/progression/urls.py index a2fd910..cef076c 100644 --- a/progression/urls.py +++ b/progression/urls.py @@ -2,6 +2,9 @@ from django.urls import path from . import views app_name = 'progression' -urlpatterns = [ +urlpatterns = [ + # On ne garde QUE la route AJAX ici. + # Les routes 'list', 'detail', etc. doivent rester dans courses/urls.py + path('ajax/toggle-lesson/', views.toggle_lesson_completion, name='toggle_lesson'), ] \ No newline at end of file diff --git a/progression/views.py b/progression/views.py index 91ea44a..1cee11c 100644 --- a/progression/views.py +++ b/progression/views.py @@ -1,3 +1,41 @@ -from django.shortcuts import render +from django.shortcuts import render, get_object_or_404 +import json +from django.http import JsonResponse +from django.views.decorators.http import require_POST +from django.contrib.auth.decorators import login_required +from progression.models import Progression +from courses.models import Lesson -# Create your views here. +@login_required +@require_POST +def toggle_lesson_completion(request): + data = json.loads(request.body) + lesson_id = data.get('lesson_id') + + lesson = get_object_or_404(Lesson, id=lesson_id) + # On remonte au cours via le module (Lesson -> Module -> Course) + course = lesson.module.course + + # On récupère ou crée la progression + progression, created = Progression.objects.get_or_create( + user=request.user, + course=course + ) + + # La logique du Toggle + if lesson in progression.completed_lessons.all(): + progression.completed_lessons.remove(lesson) + is_completed = False + else: + progression.completed_lessons.add(lesson) + is_completed = True + + # Mise à jour de la dernière leçon vue + progression.last_viewed_lesson = lesson + progression.save() + + return JsonResponse({ + 'status': 'success', + 'is_completed': is_completed, + 'new_percent': progression.percent_completed + }) \ No newline at end of file diff --git a/static/css/app.css b/static/css/app.css index d95a712..367520c 100644 --- a/static/css/app.css +++ b/static/css/app.css @@ -247,9 +247,9 @@ html { box-sizing: border-box; } gap: 6px; padding: 2px 8px; margin-left: 8px; - border-radius: 999px; + border-radius: 2px; background: var(--accent); - color: var(--warning-contrast); + color: var(--text); font-size: 11px; font-weight: 800; letter-spacing: .3px; @@ -259,6 +259,40 @@ html { box-sizing: border-box; } white-space: nowrap; } +.current-tag { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 2px 8px; + border-radius: 2px; + background: var(--primary); + color: var(--primary-contrast); + font-size: 11px; + font-weight: 800; + letter-spacing: .3px; + line-height: 1.2; + vertical-align: middle; + text-transform: uppercase; + white-space: nowrap; +} + +.completed-tag { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 2px 8px; + border-radius: 2px; + background: var(--success); + color: var(--success-contrast); + font-size: 11px; + font-weight: 800; + letter-spacing: .3px; + line-height: 1.2; + vertical-align: middle; + text-transform: uppercase; + white-space: nowrap; +} + .courseToc .tocLink.disabled { color: var(--text-muted); background: transparent; @@ -269,6 +303,70 @@ html { box-sizing: border-box; } font-weight: 500; } +/* Progression des cours */ + +.progress-container { + display: flex; + flex-direction: row; +} + +.progress-bar { + display: flex; + border-radius: var(--r-2); + border: 1px solid var(--border); + height: 20px; + background: var(--neutral-900); +} + +.progress-text { + display: flex; + align-items: center; + justify-content: center; + gap: 4px; + color: var(--text); + font-size: 12px; + margin-left: 10px; +} + +.progress-bar-fill { + background: var(--success); + height: 100%; + border-radius: var(--r-2) 0 0 var(--r-2); +} + +.course-completed { + display: flex; + flex-direction: row; + border-radius: var(--r-2); + border: 1px solid var(--border); + margin: var(--space-2) 0; + background: var(--success); +} + +.course-completed .container { + display: flex; + flex-direction: column; + padding: var(--space-2); + height: 100%; + vertical-align: middle; +} + +.course-completed .container .icon { + color: var(--success-contrast); + font-size: 50px; +} + +.course-completed .container .title { + color: var(--success-contrast); + font-size: 28px; + font-weight: 600; +} + +.course-completed .container .content { + color: var(--success-contrast); + font-size: 14px; +} + [data-theme='light'] { /* Palette: plus nuancé, moins "blanc" */ --bg: #eef3f7; /* fond légèrement teinté bleu-gris */ diff --git a/templates/courses/partials/_course_header.html b/templates/courses/partials/_course_header.html index bff069f..33fbd36 100644 --- a/templates/courses/partials/_course_header.html +++ b/templates/courses/partials/_course_header.html @@ -4,3 +4,22 @@ Un cours proposé par {{ course.author }}

{{ course.content }}

+Progression pour ce cours +{% if user_progress.percent_completed == 100 %} +
+
+
+
+
+
Félicitation
+
Tu as terminé(e) ce cours ! Tu peux réellement être fier(e) de toi !!
+
+
+{% else %} +
+
+
+
+
{{ user_progress.percent_completed }}%
+
+{% endif %} diff --git a/templates/courses/partials/_course_toc.html b/templates/courses/partials/_course_toc.html index 9039157..1389e55 100644 --- a/templates/courses/partials/_course_toc.html +++ b/templates/courses/partials/_course_toc.html @@ -13,7 +13,8 @@
  • {{ item.name }} {% if item.is_premium %}PREMIUM{% endif %} - {% if lesson and lesson.id == item.id %}(cours actuel){% endif %} + {% if lesson and lesson.id == item.id %}(cours actuel){% endif %} + {% if item.id in completed_lesson_ids %}Terminé{% endif %} {% if lesson and lesson.id == item.id %}
    @@ -69,6 +70,12 @@
    Ce que l'on voit durant ce cours :
    {{ lesson.content|comment_markdown }} + + {% if lesson.id in completed_lesson_ids %} + + {% else %} + + {% endif %}

    Commentaires

    @@ -251,6 +258,64 @@ }); })(); + + +
    diff --git a/templates/layout.html b/templates/layout.html index 2c3189d..2e7a521 100644 --- a/templates/layout.html +++ b/templates/layout.html @@ -39,8 +39,8 @@ (function() { try { var stored = localStorage.getItem('pdz-theme'); - var prefersLight = window.matchMedia && window.matchMedia('(prefers-color-scheme: light)').matches; - var theme = stored || (prefersLight ? 'light' : 'dark'); + var prefersLight = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches; + var theme = stored || (prefersLight ? 'dark' : 'light'); document.documentElement.setAttribute('data-theme', theme); if (theme === 'light') { var link = document.getElementById('theme-css'); From 82c2e234e3664215d76eefb041a4f9a8b12c84be Mon Sep 17 00:00:00 2001 From: mrtoine Date: Mon, 15 Dec 2025 22:34:39 +0100 Subject: [PATCH 03/38] =?UTF-8?q?Mise=20=C3=A0=20jour=20de=20la=20version?= =?UTF-8?q?=20applicative=20dans=20`VERSION.txt`.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- VERSION.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/VERSION.txt b/VERSION.txt index 68b8593..f388e81 100644 --- a/VERSION.txt +++ b/VERSION.txt @@ -1 +1 @@ -1.2.1 (4a48425) \ No newline at end of file +1.3.0 (ac8ef68) \ No newline at end of file From e44e31bfb95f47fbe1442717b2df9ec97c6fda29 Mon Sep 17 00:00:00 2001 From: mrtoine Date: Mon, 15 Dec 2025 22:54:27 +0100 Subject: [PATCH 04/38] =?UTF-8?q?Ajout=20du=20tableau=20de=20bord=20statis?= =?UTF-8?q?tiques=20pour=20les=20superadministrateurs,=20avec=20cache=20et?= =?UTF-8?q?=20graphiques=20JSON,=20et=20mise=20=C3=A0=20jour=20des=20route?= =?UTF-8?q?s=20pour=20inclure=20la=20vue=20correspondante.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- home/urls.py | 2 + home/views.py | 153 +++++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 154 insertions(+), 1 deletion(-) diff --git a/home/urls.py b/home/urls.py index a1ea3c1..80f5069 100644 --- a/home/urls.py +++ b/home/urls.py @@ -5,4 +5,6 @@ app_name = 'home' urlpatterns = [ path('', views.home, name='home'), path('premium/', views.premium, name='premium'), + # Tableau de bord statistiques (réservé superadministrateurs) + path('dashboard/stats/', views.stats_dashboard, name='stats_dashboard'), ] \ No newline at end of file diff --git a/home/views.py b/home/views.py index 5bd3838..9702e82 100644 --- a/home/views.py +++ b/home/views.py @@ -1,5 +1,13 @@ from django.shortcuts import render, get_object_or_404 -from courses.models import Course +from django.contrib.auth.decorators import user_passes_test +from django.utils import timezone +from django.db.models import Count +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 def home(request): courses = Course.objects.order_by('-created_at')[:6] @@ -9,3 +17,146 @@ 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] + try: + p = int(request.GET.get('p', 30)) + except ValueError: + p = 30 + if p not in period_options: + p = 30 + + now = timezone.now() + start_dt = now - timezone.timedelta(days=p-1) # inclut aujourd'hui + + # Utilisateurs + total_users = User.objects.count() + new_users_qs = User.objects.filter(date_joined__date__gte=start_dt.date(), date_joined__date__lte=now.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=start_dt.date(), updated_at__date__lte=now.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=start_dt.date(), created_at__date__lte=now.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=start_dt.date(), updated_at__date__lte=now.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=start_dt.date(), created_at__date__lte=now.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 + + # 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} + days = [] + values = [] + d = start_dt.date() + while d <= now.date(): + key = str(d) + days.append(key) + values.append(counts.get(key, 0)) + d += timezone.timedelta(days=1) + return days, values + + 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': start_dt.date(), + 'end_date': now.date(), + + # KPI + 'kpi': { + 'total_users': total_users, + 'new_users_period': sum(values_new_users), + 'active_users_period': active_users_count, + '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, + } + + # 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) From 2c715a3af437a28c509e95081ff44dcccede5df2 Mon Sep 17 00:00:00 2001 From: mrtoine Date: Mon, 15 Dec 2025 22:55:01 +0100 Subject: [PATCH 05/38] =?UTF-8?q?Mise=20=C3=A0=20jour=20de=20la=20version?= =?UTF-8?q?=20applicative=20dans=20`VERSION.txt`.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- VERSION.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/VERSION.txt b/VERSION.txt index f388e81..6da9a00 100644 --- a/VERSION.txt +++ b/VERSION.txt @@ -1 +1 @@ -1.3.0 (ac8ef68) \ No newline at end of file +1.3.1 (e44e31b) \ No newline at end of file From ab8307b272a9fc4bff75ea670d31ffd4c852bd84 Mon Sep 17 00:00:00 2001 From: mrtoine Date: Mon, 15 Dec 2025 23:02:43 +0100 Subject: [PATCH 06/38] Ajout du template `stats_dashboard.html` pour afficher le tableau de bord des statistiques, avec styles, graphiques `Chart.js`, tableaux dynamiques et gestion du contexte. --- templates/home/stats_dashboard.html | 135 ++++++++++++++++++++++++++++ 1 file changed, 135 insertions(+) create mode 100644 templates/home/stats_dashboard.html diff --git a/templates/home/stats_dashboard.html b/templates/home/stats_dashboard.html new file mode 100644 index 0000000..e5daaed --- /dev/null +++ b/templates/home/stats_dashboard.html @@ -0,0 +1,135 @@ +{% extends "layout.html" %} +{% load static %} + +{% block title %}- Tableau de bord{% endblock %} + +{% block extra_head %} + + +{% endblock %} + +{% block content %} +
    +

    Tableau de bord statistiques

    + +
    +
    + + + Du {{ start_date }} au {{ end_date }} +
    +
    Mise en cache 15 minutes
    +
    + +
    +
    +

    Utilisateurs (total)

    +
    {{ kpi.total_users }}
    +
    +
    +

    Nouveaux utilisateurs (période)

    +
    {{ kpi.new_users_period }}
    +
    +
    +

    Utilisateurs actifs (période)

    +
    {{ kpi.active_users_period }}
    +
    +
    +

    Cours (publiés / total)

    +
    {{ kpi.courses_enabled }} / {{ kpi.total_courses }}
    +
    +
    +

    Leçons (total)

    +
    {{ kpi.total_lessons }}
    +
    +
    +

    Articles de blog (total)

    +
    {{ kpi.total_posts }}
    +
    +
    +

    Revenus

    + {% if revenus_disponibles %} +
    + {% else %} +
    N/A
    + {% endif %} +
    +
    +

    Technique

    + {% if technique_disponible %} +
    + {% else %} +
    N/A
    + {% endif %} +
    +
    + +
    +
    +

    Évolution quotidienne

    + +
    +
    + +
    +
    +

    Nouveaux utilisateurs par jour

    + + + + {% for row in new_users_table %} + + {% endfor %} + +
    JourNb
    {{ row.0 }}{{ row.1 }}
    +
    +
    +

    Nouveaux cours par jour

    + + + + {% for row in new_courses_table %} + + {% endfor %} + +
    JourNb
    {{ row.0 }}{{ row.1 }}
    +
    +
    +
    + + +{% endblock %} From ca0211e841cc012a031c2cb97e8d6e37067ed043 Mon Sep 17 00:00:00 2001 From: mrtoine Date: Mon, 15 Dec 2025 23:03:16 +0100 Subject: [PATCH 07/38] =?UTF-8?q?Mise=20=C3=A0=20jour=20de=20la=20version?= =?UTF-8?q?=20applicative=20dans=20`VERSION.txt`.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- VERSION.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/VERSION.txt b/VERSION.txt index 6da9a00..cb7e607 100644 --- a/VERSION.txt +++ b/VERSION.txt @@ -1 +1 @@ -1.3.1 (e44e31b) \ No newline at end of file +1.3.2 (ab8307b) \ No newline at end of file From fc4939577ab5fc180b725d7d5cc6629f11d044b7 Mon Sep 17 00:00:00 2001 From: mrtoine Date: Tue, 16 Dec 2025 10:19:47 +0100 Subject: [PATCH 08/38] =?UTF-8?q?Ajout=20de=20la=20gestion=20de=20l'activa?= =?UTF-8?q?tion=20des=20comptes=20utilisateur=20par=20e-mail,=20des=20vali?= =?UTF-8?q?dations=20pour=20les=20pseudos=20existants,=20et=20configuratio?= =?UTF-8?q?n=20par=20d=C3=A9faut=20de=20l'e-mail=20backend.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- devart/settings.py | 5 +++- users/forms.py | 10 +++++++- users/tokens.py | 4 ++++ users/urls.py | 2 ++ users/views.py | 57 ++++++++++++++++++++++++++++++++++++++++++++-- 5 files changed, 74 insertions(+), 4 deletions(-) create mode 100644 users/tokens.py diff --git a/devart/settings.py b/devart/settings.py index 2736323..a352b3f 100644 --- a/devart/settings.py +++ b/devart/settings.py @@ -202,4 +202,7 @@ def get_git_version(): else: return "Version inconnue (Fichier manquant)" -GIT_VERSION = get_git_version() \ No newline at end of file +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 diff --git a/users/forms.py b/users/forms.py index 2335d4c..362845e 100644 --- a/users/forms.py +++ b/users/forms.py @@ -27,7 +27,15 @@ class UserRegistrationForm(forms.Form): password2 = cleaned_data.get("password2") if password1 and password2 and password1 != password2: - raise forms.ValidationError("Passwords do not match") + raise forms.ValidationError("Les mots de passe ne correspondent pas.") + + return cleaned_data + + def clean_username(self): + username = self.cleaned_data.get('username') + if username and User.objects.filter(username__iexact=username).exists(): + raise forms.ValidationError("Ce pseudo est déjà pris. Veuillez en choisir un autre.") + return username class UserLoginForm(forms.Form): username = forms.CharField( diff --git a/users/tokens.py b/users/tokens.py new file mode 100644 index 0000000..bc82caf --- /dev/null +++ b/users/tokens.py @@ -0,0 +1,4 @@ +from django.contrib.auth.tokens import PasswordResetTokenGenerator + +# Générateur de tokens pour l'activation de compte +activation_token = PasswordResetTokenGenerator() diff --git a/users/urls.py b/users/urls.py index cdf7a52..e46826d 100644 --- a/users/urls.py +++ b/users/urls.py @@ -7,6 +7,8 @@ urlpatterns = [ path('', views.register, name='register'), path('login/', views.login, name='login'), path('logout/', views.logout, name='logout'), + # Activation de compte par lien tokenisé + path('activate///', views.activate, name='activate'), path('profile/view//', views.another_profile, name='another_profile'), path('complete-profile/', views.complete_profile, name='complete_profile'), path('profile/', views.profile, name='profile'), diff --git a/users/views.py b/users/views.py index 7b6ce5e..bf3c51d 100644 --- a/users/views.py +++ b/users/views.py @@ -5,6 +5,11 @@ from django.contrib.auth.models import User from courses.models import Course from .forms import UserRegistrationForm, UserLoginForm, UserUpdateForm, ProfileUpdateForm, CompleteProfileForm from django.contrib.auth.decorators import login_required +from django.core.mail import send_mail +from django.urls import reverse +from django.utils.http import urlsafe_base64_encode, urlsafe_base64_decode +from django.utils.encoding import force_bytes +from .tokens import activation_token def register(request): # Si l'utilisateur est deja connecté, on le redirige vers la page de profil @@ -13,13 +18,36 @@ def register(request): if request.method == 'POST': form = UserRegistrationForm(request.POST) if form.is_valid(): + # Crée un utilisateur inactif en attente d'activation par e‑mail user = User.objects.create_user( username=form.cleaned_data['username'], email=form.cleaned_data['email'], password=form.cleaned_data['password1'] ) - auth_login(request, user) - return redirect('profile') + user.is_active = False + user.save() + + # Envoi du lien d'activation par e‑mail + uid = urlsafe_base64_encode(force_bytes(user.pk)) + token = activation_token.make_token(user) + activation_link = request.build_absolute_uri( + reverse('activate', kwargs={'uidb64': uid, 'token': token}) + ) + subject = 'Active ton compte sur partirdezero' + message = ( + 'Bienvenue !\n\n' + 'Pour activer ton compte, clique sur le lien suivant (valide pendant une durée limitée) :\n' + f'{activation_link}\n\n' + "Si tu n'es pas à l'origine de cette inscription, ignore ce message." + ) + try: + send_mail(subject, message, None, [user.email], fail_silently=True) + except Exception: + # Même si l'envoi échoue, on n'interrompt pas le flux; un admin vérifiera la config e‑mail + pass + + messages.success(request, "Inscription réussie. Vérifie ta boîte mail pour activer ton compte.") + return redirect('login') else: form = UserRegistrationForm() return render(request, 'users/register.html', {'form': form}) @@ -32,8 +60,15 @@ def login(request): password = form.cleaned_data['password'] user = authenticate(request, username=username, password=password) if user is not None: + if not user.is_active: + messages.error(request, "Votre compte n'est pas encore activé. Consultez l'e‑mail d'activation envoyé." ) + return render(request, 'users/login.html', {'form': form}) auth_login(request, user) return redirect('profile') + else: + # Identifiants invalides: avertir l'utilisateur + messages.error(request, "Nom d'utilisateur ou mot de passe incorrect.") + return render(request, 'users/login.html', {'form': form}) else: form = UserLoginForm() return render(request, 'users/login.html', {'form': form}) @@ -106,3 +141,21 @@ def create_post(request): def another_profile(request, user_id): user = User.objects.get(id=user_id) return render(request, 'users/another_profile.html', {'user': user}) + +# Activation de compte via lien tokenisé +def activate(request, uidb64, token): + try: + uid = urlsafe_base64_decode(uidb64).decode() + user = User.objects.get(pk=uid) + except Exception: + user = None + + if user and activation_token.check_token(user, token): + if not user.is_active: + user.is_active = True + user.save() + messages.success(request, 'Votre compte a été activé. Vous pouvez maintenant vous connecter.') + return redirect('login') + + messages.error(request, "Lien d'activation invalide ou expiré. Demandez un nouveau lien ou inscrivez‑vous à nouveau.") + return redirect('register') From b3b18bd15ae65a848e87a3e5f3b5aa2c426cc752 Mon Sep 17 00:00:00 2001 From: mrtoine Date: Tue, 16 Dec 2025 10:20:12 +0100 Subject: [PATCH 09/38] =?UTF-8?q?Mise=20=C3=A0=20jour=20de=20la=20version?= =?UTF-8?q?=20applicative=20dans=20`VERSION.txt`.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- VERSION.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/VERSION.txt b/VERSION.txt index cb7e607..572f6ef 100644 --- a/VERSION.txt +++ b/VERSION.txt @@ -1 +1 @@ -1.3.2 (ab8307b) \ No newline at end of file +1.3.2 (ca0211e) \ No newline at end of file From bec74976ba95c5c731b880491df84c04309b3c00 Mon Sep 17 00:00:00 2001 From: mrtoine Date: Tue, 16 Dec 2025 10:20:26 +0100 Subject: [PATCH 10/38] =?UTF-8?q?Mise=20=C3=A0=20jour=20de=20la=20version?= =?UTF-8?q?=20applicative=20dans=20`VERSION.txt`.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- VERSION.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/VERSION.txt b/VERSION.txt index 572f6ef..63c4a6a 100644 --- a/VERSION.txt +++ b/VERSION.txt @@ -1 +1 @@ -1.3.2 (ca0211e) \ No newline at end of file +1.3.3 (b3b18bd) \ No newline at end of file From 6e8a2bc2875e727286d6f5b3e5bf2fa517f29dd5 Mon Sep 17 00:00:00 2001 From: mrtoine Date: Tue, 16 Dec 2025 10:28:20 +0100 Subject: [PATCH 11/38] =?UTF-8?q?Ajout=20du=20suivi=20des=20visites=20:=20?= =?UTF-8?q?mod=C3=A8le=20`Visit`,=20middleware=20de=20tracking,=20mises=20?= =?UTF-8?q?=C3=A0=20jour=20des=20vues=20et=20du=20tableau=20de=20bord=20st?= =?UTF-8?q?atistiques.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- core/admin.py | 9 ++- core/middleware.py | 117 ++++++++++++++++++++++++++++ core/migrations/0004_visit.py | 41 ++++++++++ core/models.py | 36 ++++++++- devart/settings.py | 8 +- home/views.py | 39 ++++++++++ templates/home/stats_dashboard.html | 41 ++++++++++ 7 files changed, 286 insertions(+), 5 deletions(-) create mode 100644 core/middleware.py create mode 100644 core/migrations/0004_visit.py 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
    +
    +
    + +{% endblock %} + +{% block content %} +
    +

    Graphiques statistiques

    + +
    +
    + + + Du {{ start_date }} au {{ end_date }} +
    +
    Mise en cache 15 minutes
    +
    + +
    +
    +

    Visiteurs uniques par jour

    + +
    +
    +

    Conversions (visiteurs devenus utilisateurs) par jour

    + +
    +
    + +
    +
    +

    Top sources (visiteurs uniques)

    + +
    +
    +

    Top pays (visiteurs uniques)

    + +
    +
    + +

    ← Retour au tableau de bord

    +
    + + +{% endblock %} From e1f8a23f3df8c5781a0c220bdc251db746553fb0 Mon Sep 17 00:00:00 2001 From: mrtoine Date: Tue, 16 Dec 2025 10:53:26 +0100 Subject: [PATCH 16/38] =?UTF-8?q?Mise=20=C3=A0=20jour=20de=20la=20version?= =?UTF-8?q?=20applicative=20dans=20`VERSION.txt`.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- VERSION.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/VERSION.txt b/VERSION.txt index 3894f62..299b2e2 100644 --- a/VERSION.txt +++ b/VERSION.txt @@ -1 +1 @@ -1.3.5 (7869abf) \ No newline at end of file +1.3.5 (1b0ccc5) \ No newline at end of file From 91f7f795469c69ad71ccf5ff5f930ed9976ff5d0 Mon Sep 17 00:00:00 2001 From: mrtoine Date: Tue, 16 Dec 2025 13:10:43 +0100 Subject: [PATCH 17/38] =?UTF-8?q?Ajout=20des=20d=C3=A9corations=20et=20ani?= =?UTF-8?q?mations=20de=20neige=20pour=20les=20f=C3=AAtes=20de=20fin=20d'a?= =?UTF-8?q?nn=C3=A9e,=20charg=C3=A9es=20conditionnellement=20en=20d=C3=A9c?= =?UTF-8?q?embre.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- static/js/functions.js | 26 +----------------------- templates/layout.html | 36 +++++++++++++++++++++++++++++++++ templates/partials/_footer.html | 6 +++++- templates/partials/_header.html | 7 ++++++- 4 files changed, 48 insertions(+), 27 deletions(-) diff --git a/static/js/functions.js b/static/js/functions.js index db8ff92..500562a 100644 --- a/static/js/functions.js +++ b/static/js/functions.js @@ -94,28 +94,4 @@ document.addEventListener('DOMContentLoaded', function() { }); } } catch(e) {} -}); - -// Fonction pour générer des flocons de neige -function createSnowflake() { - const snowflake = document.createElement('div'); - snowflake.classList.add('snowflake'); - snowflake.textContent = '•'; - - snowflake.style.left = `${Math.random() * 100}vw`; - - const size = Math.random() * 1.5 + 0.5; - snowflake.style.fontSize = `${size}em`; - - const duration = Math.random() * 5 + 5; - snowflake.style.animationDuration = `${duration}s`; - - document.body.appendChild(snowflake); - - setTimeout(() => { - snowflake.remove(); - }, duration * 1000); -} - -// On génère les flocons toutes les 300ms -setInterval(createSnowflake, 300); \ No newline at end of file +}); \ No newline at end of file diff --git a/templates/layout.html b/templates/layout.html index 2e7a521..2a39086 100644 --- a/templates/layout.html +++ b/templates/layout.html @@ -31,6 +31,37 @@ + {% now "n" as month %} + {% if month == '12' %} + + + + {% endif %} + {% block extra_head %}{% endblock %} @@ -53,6 +84,11 @@ + {% now "n" as month %} + {% if month == '12' %} + + + {% endif %} {% block header %} {% include "partials/_header.html" %} {% endblock %} diff --git a/templates/partials/_footer.html b/templates/partials/_footer.html index 29b2d41..934a5e3 100644 --- a/templates/partials/_footer.html +++ b/templates/partials/_footer.html @@ -1,4 +1,5 @@ -
    +{% now "n" as month %} +
    \ No newline at end of file diff --git a/templates/partials/_header.html b/templates/partials/_header.html index 20db214..ea97ebe 100644 --- a/templates/partials/_header.html +++ b/templates/partials/_header.html @@ -2,7 +2,12 @@
    {% if settings.site_logo %}{% endif %} - Partir de zéro + Partir de zéro + {% now "n" as month %} + {% if month == '12' %} + + {% endif %} +
    /* Anthony Violet */
    From 5a241e394b81cf9ca264e614b716a8273fa8f7ae Mon Sep 17 00:00:00 2001 From: mrtoine Date: Tue, 16 Dec 2025 13:12:00 +0100 Subject: [PATCH 18/38] =?UTF-8?q?Mise=20=C3=A0=20jour=20de=20la=20version?= =?UTF-8?q?=20applicative=20dans=20`VERSION.txt`.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- VERSION.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/VERSION.txt b/VERSION.txt index 299b2e2..3b36bbe 100644 --- a/VERSION.txt +++ b/VERSION.txt @@ -1 +1 @@ -1.3.5 (1b0ccc5) \ No newline at end of file +1.3.6 (91f7f79) \ No newline at end of file From 5fd06f5ae18559b45fcdd75245358ebf2bf41d54 Mon Sep 17 00:00:00 2001 From: mrtoine Date: Tue, 16 Dec 2025 13:14:15 +0100 Subject: [PATCH 19/38] =?UTF-8?q?Ajout=20de=20la=20feuille=20de=20style=20?= =?UTF-8?q?`christmas.css`=20pour=20les=20d=C3=A9corations=20festives=20et?= =?UTF-8?q?=20animations=20de=20neige,=20charg=C3=A9es=20conditionnellemen?= =?UTF-8?q?t=20en=20d=C3=A9cembre.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- static/css/christmas.css | 99 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 static/css/christmas.css diff --git a/static/css/christmas.css b/static/css/christmas.css new file mode 100644 index 0000000..19cf591 --- /dev/null +++ b/static/css/christmas.css @@ -0,0 +1,99 @@ +/* PartirDeZero — Décos de Noël (chargées uniquement en décembre) */ + +/* Barre festive discrète sous la navbar */ +.site-nav { position: relative; } +.site-nav::after { + content: ""; + position: absolute; + left: 0; right: 0; bottom: -1px; + height: 4px; + background: repeating-linear-gradient(45deg, + #d61c4e 0 12px, + #1b8f3a 12px 24px, + #ffffff 24px 36px); + opacity: .55; + pointer-events: none; +} + +/* Emoji sapin à côté du titre */ +.pdz-festive-emoji { + margin-left: .35rem; + filter: drop-shadow(0 1px 0 rgba(0,0,0,.2)); +} + +/* Overlay neige — ultra léger, non bloquant */ +.pdz-snow { + position: fixed; + inset: 0; + pointer-events: none; + z-index: 5; /* au-dessus du fond, sous les modales si existantes */ + background-image: + radial-gradient(2px 2px at 20px 30px, rgba(255,255,255,.9) 50%, rgba(255,255,255,0) 51%), + radial-gradient(3px 3px at 80px 120px, rgba(255,255,255,.8) 50%, rgba(255,255,255,0) 51%), + radial-gradient(2px 2px at 150px 80px, rgba(255,255,255,.85) 50%, rgba(255,255,255,0) 51%), + radial-gradient(3px 3px at 250px 20px, rgba(255,255,255,.8) 50%, rgba(255,255,255,0) 51%); + background-repeat: repeat; + background-size: 300px 200px, 400px 300px, 350px 250px, 500px 400px; + animation: pdzSnowFall 18s linear infinite; +} + +/* Plusieurs couches pour un effet de profondeur via parallax */ +.pdz-snow::before, +.pdz-snow::after { + content: ""; + position: absolute; inset: 0; + background-image: + radial-gradient(2px 2px at 40px 60px, rgba(255,255,255,.8) 50%, rgba(255,255,255,0) 51%), + radial-gradient(3px 3px at 120px 200px, rgba(255,255,255,.75) 50%, rgba(255,255,255,0) 51%), + radial-gradient(2px 2px at 220px 160px, rgba(255,255,255,.8) 50%, rgba(255,255,255,0) 51%); + background-repeat: repeat; +} +.pdz-snow::before { + background-size: 260px 180px, 380px 280px, 320px 220px; + animation: pdzSnowFallSlow 28s linear infinite; +} +.pdz-snow::after { + background-size: 200px 140px, 300px 220px, 260px 200px; + animation: pdzSnowFallFast 12s linear infinite; +} + +@keyframes pdzSnowFall { + from { transform: translateY(-10%); } + to { transform: translateY(100%); } +} +@keyframes pdzSnowFallSlow { + from { transform: translateY(-10%); } + to { transform: translateY(100%); } +} +@keyframes pdzSnowFallFast { + from { transform: translateY(-10%); } + to { transform: translateY(100%); } +} + +/* Respect des préférences d'accessibilité */ +@media (prefers-reduced-motion: reduce) { + .pdz-snow, .pdz-snow::before, .pdz-snow::after { animation: none; } +} + +/* ——— Footer: petite touche festive discrète ——— */ +footer.pdz-xmas { position: relative; } +footer.pdz-xmas::before { + content: ""; + position: absolute; + left: 0; right: 0; top: 0; + height: 4px; + background: repeating-linear-gradient(45deg, + #d61c4e 0 12px, + #1b8f3a 12px 24px, + #ffffff 24px 36px); + opacity: .55; + pointer-events: none; +} + +.footer-legal .pdz-holiday-greeting { + display: inline-flex; + align-items: center; + gap: .35rem; + color: var(--fg); + font-weight: 500; +} From afa673ccd991527656ad33eaec17f204f9e6e5bf Mon Sep 17 00:00:00 2001 From: mrtoine Date: Tue, 16 Dec 2025 13:23:15 +0100 Subject: [PATCH 20/38] =?UTF-8?q?Tri=20des=20articles=20par=20date=20de=20?= =?UTF-8?q?cr=C3=A9ation=20d=C3=A9croissante=20dans=20le=20`context=5Fproc?= =?UTF-8?q?essor`.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- blog/context_processor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/blog/context_processor.py b/blog/context_processor.py index a1666a4..d09e196 100644 --- a/blog/context_processor.py +++ b/blog/context_processor.py @@ -1,5 +1,5 @@ from .models import Post def posts_list(request): - posts = Post.objects.all() + posts = Post.objects.all().order_by('-created_at') return {'posts': posts} \ No newline at end of file From 81b42b8b4aead54c160f9b48941ec1f0c7acf2f0 Mon Sep 17 00:00:00 2001 From: mrtoine Date: Tue, 16 Dec 2025 13:23:58 +0100 Subject: [PATCH 21/38] =?UTF-8?q?Mise=20=C3=A0=20jour=20de=20la=20version?= =?UTF-8?q?=20applicative=20dans=20`VERSION.txt`.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- VERSION.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/VERSION.txt b/VERSION.txt index 3b36bbe..e8b8e67 100644 --- a/VERSION.txt +++ b/VERSION.txt @@ -1 +1 @@ -1.3.6 (91f7f79) \ No newline at end of file +1.3.7 (afa673c) \ No newline at end of file From 8f0fad45be0b50c5b9e3bdadfdb4ce2d72f0dde9 Mon Sep 17 00:00:00 2001 From: mrtoine Date: Tue, 16 Dec 2025 13:55:14 +0100 Subject: [PATCH 22/38] =?UTF-8?q?Affichage=20de=20la=20progression=20utili?= =?UTF-8?q?sateur=20dans=20les=20cours=20suivis=20et=20am=C3=A9lioration?= =?UTF-8?q?=20des=20templates=20associ=C3=A9s.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- devart/sitemap.py | 2 +- templates/users/my_courses.html | 27 ++++++++++++++++++----- templates/users/profile.html | 39 ++++++++++++++++++--------------- users/views.py | 26 ++++++++++++++++++---- 4 files changed, 65 insertions(+), 29 deletions(-) diff --git a/devart/sitemap.py b/devart/sitemap.py index e761d33..c675d9d 100644 --- a/devart/sitemap.py +++ b/devart/sitemap.py @@ -12,7 +12,7 @@ class CourseSitemap(sitemaps.Sitemap): priority = 0.9 def items(self): - return Course.objects.filter(enable=True) # Exemple de filtre + return Course.objects.filter(enable=True).order_by('id') def location(self, item): # Assure-toi que ton modèle Course a bien une méthode get_absolute_url diff --git a/templates/users/my_courses.html b/templates/users/my_courses.html index f672141..dab8a7f 100644 --- a/templates/users/my_courses.html +++ b/templates/users/my_courses.html @@ -6,12 +6,27 @@ {% endblock %}

    Mes cours

    -

    Retrouvez ici la liste de tous les cours que vous suivez.

    - +

    Retrouvez ici la liste de tous les cours que vous suivez et votre progression.

    + + {% if progress_list %} + + {% else %} +

    Vous ne suivez aucun cours pour le moment.

    + {% endif %}
    {% endblock %} \ No newline at end of file diff --git a/templates/users/profile.html b/templates/users/profile.html index 57be63e..f9aae18 100644 --- a/templates/users/profile.html +++ b/templates/users/profile.html @@ -22,24 +22,27 @@

    Mes cours

    - {% with courses=user.course_set.all %} - {% if courses %} - - - {% else %} -

    Aucun cours suivi pour le moment.

    - {% endif %} + {% with progress_list=latest_progress %} + {% if progress_list %} +
      + {% for p in progress_list %} + {% with course=p.course %} +
    • + + {{ course.name }} + {{ course.name }} + +
      Progression: {{ p.percent_completed }}%
      +
    • + {% endwith %} + {% endfor %} +
    + + {% else %} +

    Aucun cours suivi pour le moment.

    + {% endif %} {% endwith %}
    diff --git a/users/views.py b/users/views.py index bf3c51d..a6d89cc 100644 --- a/users/views.py +++ b/users/views.py @@ -3,6 +3,7 @@ from django.contrib import messages from django.contrib.auth import authenticate, login as auth_login, logout as auth_logout from django.contrib.auth.models import User from courses.models import Course +from progression.models import Progression from .forms import UserRegistrationForm, UserLoginForm, UserUpdateForm, ProfileUpdateForm, CompleteProfileForm from django.contrib.auth.decorators import login_required from django.core.mail import send_mail @@ -81,7 +82,17 @@ def logout(request): def profile(request): if not hasattr(request.user, 'profile'): return redirect('complete_profile') - return render(request, 'users/profile.html') + + latest_progress = ( + Progression.objects + .filter(user=request.user) + .select_related('course') + .prefetch_related('completed_lessons') + .order_by('-updated_at')[:5] + ) + + # Affiche les 5 derniers cours regardés avec leur progression + return render(request, 'users/profile.html', {'latest_progress': latest_progress}) @login_required(login_url='login') def complete_profile(request): @@ -130,9 +141,16 @@ def account_update(request): @login_required(login_url='login') def my_courses(request): - user_courses = Course.objects.filter(author=request.user.id) - print(user_courses) - return render(request, 'users/my_courses.html', {'user_courses' : user_courses}) + # Liste tous les cours suivis par l'utilisateur avec leur progression + progress_list = ( + Progression.objects + .filter(user=request.user) + .select_related('course') + .prefetch_related('completed_lessons') + .order_by('-updated_at') + ) + + return render(request, 'users/my_courses.html', {'progress_list': progress_list}) def create_post(request): # Implement post creation logic here From a7b51e3a82677db087970ca13a8e075940496313 Mon Sep 17 00:00:00 2001 From: mrtoine Date: Tue, 16 Dec 2025 13:55:46 +0100 Subject: [PATCH 23/38] =?UTF-8?q?Mise=20=C3=A0=20jour=20de=20la=20version?= =?UTF-8?q?=20applicative=20dans=20`VERSION.txt`.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- VERSION.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/VERSION.txt b/VERSION.txt index e8b8e67..c96e38c 100644 --- a/VERSION.txt +++ b/VERSION.txt @@ -1 +1 @@ -1.3.7 (afa673c) \ No newline at end of file +1.3.7 (8f0fad4) \ No newline at end of file From 7cf04968eba748426ce33ccdad45892af93aafde Mon Sep 17 00:00:00 2001 From: mrtoine Date: Tue, 16 Dec 2025 14:15:58 +0100 Subject: [PATCH 24/38] =?UTF-8?q?Ajout=20de=20la=20fonctionnalit=C3=A9=20d?= =?UTF-8?q?=E2=80=99activit=C3=A9=20en=20direct=20dans=20le=20tableau=20de?= =?UTF-8?q?=20bord=20des=20statistiques=20et=20mise=20=C3=A0=20jour=20des?= =?UTF-8?q?=20templates,=20vues,=20URL,=20et=20styles=20associ=C3=A9s.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- core/middleware.py | 6 +++- home/urls.py | 1 + home/views.py | 42 ++++++++++++++++++++++ static/css/app.css | 10 ++++++ templates/home/stats_dashboard.html | 56 ++++++++++++++++++++++++++++- templates/partials/_header.html | 1 + 6 files changed, 114 insertions(+), 2 deletions(-) diff --git a/core/middleware.py b/core/middleware.py index 3e76796..3167553 100644 --- a/core/middleware.py +++ b/core/middleware.py @@ -111,7 +111,11 @@ class VisitTrackingMiddleware: # Marquer la conversion si pas encore définie if visit.became_user_at is None: visit.became_user_at = now + # Mettre à jour la page courante et l'horodatage + if visit.path != path: + visit.path = path[:512] + dirty = True visit.last_seen = now dirty = True if dirty: - visit.save(update_fields=['source', 'country', 'user', 'became_user_at', 'last_seen']) + visit.save(update_fields=['source', 'country', 'user', 'became_user_at', 'path', 'last_seen']) diff --git a/home/urls.py b/home/urls.py index 1dbd960..52e2435 100644 --- a/home/urls.py +++ b/home/urls.py @@ -8,4 +8,5 @@ urlpatterns = [ # Tableau de bord statistiques (réservé superadministrateurs) path('dashboard/stats/', views.stats_dashboard, name='stats_dashboard'), path('dashboard/stats/charts/', views.stats_charts, name='stats_charts'), + path('dashboard/stats/live-activity/', views.live_activity, name='live_activity'), ] \ No newline at end of file diff --git a/home/views.py b/home/views.py index ebd2cf2..76fba86 100644 --- a/home/views.py +++ b/home/views.py @@ -9,6 +9,7 @@ from courses.models import Course, Lesson from blog.models import Post from progression.models import Progression import json +from django.http import JsonResponse def home(request): courses = Course.objects.order_by('-created_at')[:6] @@ -293,3 +294,44 @@ def stats_charts(request): } 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}) diff --git a/static/css/app.css b/static/css/app.css index 367520c..a4ddad5 100644 --- a/static/css/app.css +++ b/static/css/app.css @@ -2330,6 +2330,16 @@ input[type="text"], input[type="email"], input[type="password"], textarea { filter: brightness(1.05); } +.btn-warning, .button-warning { + background-color: var(--warning); + border-color: var(--warning); + color: var(--warning-contrast); +} + +.btn-warning:hover, .button-warning:hover { + filter: brightness(1.05); +} + /* Tailles */ .btn-sm, .button-sm { padding: 6px 10px; font-size: 0.9rem; } .btn-lg, .button-lg { padding: 12px 20px; font-size: 1.05rem; } diff --git a/templates/home/stats_dashboard.html b/templates/home/stats_dashboard.html index 3dddab3..704ada6 100644 --- a/templates/home/stats_dashboard.html +++ b/templates/home/stats_dashboard.html @@ -20,7 +20,9 @@ {% block content %}

    Tableau de bord statistiques

    -

    → Voir la page de graphiques

    +

    + → Voir la page de graphiques +

    @@ -90,6 +92,20 @@
    +
    +
    +

    Activité en direct (5 dernières minutes)

    + + + + + + + +
    TypeIdentitéPageIl y aSourcePays
    Chargement…
    +
    +
    +

    Évolution quotidienne

    @@ -173,5 +189,43 @@ plugins: {legend: {position: 'bottom'}} } }); + + // Live activity polling + function humanizeSeconds(s) { + if (s < 60) return s + 's'; + const m = Math.floor(s/60); const r = s % 60; + if (m < 60) return m + 'm' + (r?(' '+r+'s'):''); + const h = Math.floor(m/60); const mr = m % 60; return h + 'h' + (mr?(' '+mr+'m'):''); + } + async function fetchLive() { + try { + const res = await fetch('{% url "home:live_activity" %}', {headers: {'Accept': 'application/json'}}); + if (!res.ok) throw new Error('HTTP '+res.status); + const payload = await res.json(); + const tbody = document.querySelector('#live-activity-table tbody'); + tbody.innerHTML = ''; + const items = payload.items || []; + if (items.length === 0) { + const tr = document.createElement('tr'); + const td = document.createElement('td'); td.colSpan = 6; td.textContent = 'Aucune activité récente'; + tr.appendChild(td); tbody.appendChild(tr); return; + } + for (const it of items) { + const tr = document.createElement('tr'); + const type = document.createElement('td'); type.textContent = it.is_user ? 'Utilisateur' : 'Visiteur'; + const ident = document.createElement('td'); ident.textContent = it.is_user ? (it.username || 'Utilisateur') : it.visitor; + const page = document.createElement('td'); page.textContent = it.path || '/'; + const ago = document.createElement('td'); ago.textContent = humanizeSeconds(it.seconds_ago); + const src = document.createElement('td'); src.textContent = it.source || ''; + const country = document.createElement('td'); country.textContent = it.country || ''; + tr.append(type, ident, page, ago, src, country); + tbody.appendChild(tr); + } + } catch (e) { + // silent fail in UI + } + } + fetchLive(); + setInterval(fetchLive, 10000); {% endblock %} diff --git a/templates/partials/_header.html b/templates/partials/_header.html index ea97ebe..a6938aa 100644 --- a/templates/partials/_header.html +++ b/templates/partials/_header.html @@ -48,6 +48,7 @@ {% if user.is_authenticated and user.is_staff %}
  • Admin + Stats
  • {% endif %}
  • From d20302be0efb66a06d0ae6d8c9d1d6f298c0346f Mon Sep 17 00:00:00 2001 From: mrtoine Date: Tue, 16 Dec 2025 14:16:24 +0100 Subject: [PATCH 25/38] =?UTF-8?q?Mise=20=C3=A0=20jour=20de=20la=20version?= =?UTF-8?q?=20applicative=20dans=20`VERSION.txt`.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- VERSION.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/VERSION.txt b/VERSION.txt index c96e38c..43b4937 100644 --- a/VERSION.txt +++ b/VERSION.txt @@ -1 +1 @@ -1.3.7 (8f0fad4) \ No newline at end of file +1.3.8 (7cf0496) \ No newline at end of file From 18b807bf5ae359bc547532ebcd8d9b5e321f767f Mon Sep 17 00:00:00 2001 From: mrtoine Date: Tue, 16 Dec 2025 15:03:46 +0100 Subject: [PATCH 26/38] =?UTF-8?q?Mise=20=C3=A0=20jour=20de=20la=20version?= =?UTF-8?q?=20applicative=20dans=20`VERSION.txt`.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- home/views.py | 103 ++++++++++++++++------------ templates/home/stats_charts.html | 22 +----- templates/home/stats_dashboard.html | 25 +------ 3 files changed, 65 insertions(+), 85 deletions(-) diff --git a/home/views.py b/home/views.py index 76fba86..22ab337 100644 --- a/home/views.py +++ b/home/views.py @@ -11,6 +11,49 @@ 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}) @@ -30,19 +73,13 @@ def stats_dashboard(request): # Période period_options = [7, 30, 90, 180] - try: - p = int(request.GET.get('p', 30)) - except ValueError: - p = 30 - if p not in period_options: - p = 30 - - now = timezone.now() - start_dt = now - timezone.timedelta(days=p-1) # inclut aujourd'hui + 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=start_dt.date(), date_joined__date__lte=now.date()) + 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 @@ -53,7 +90,7 @@ def stats_dashboard(request): # Activité approximée via Progression mise à jour active_users_qs = ( - Progression.objects.filter(updated_at__date__gte=start_dt.date(), updated_at__date__lte=now.date()) + 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() @@ -62,7 +99,7 @@ def stats_dashboard(request): 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=start_dt.date(), created_at__date__lte=now.date()) + 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')) ) @@ -83,7 +120,7 @@ def stats_dashboard(request): # 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=start_dt.date(), updated_at__date__lte=now.date()) + 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')) ) @@ -91,7 +128,7 @@ def stats_dashboard(request): # Blog total_posts = Post.objects.count() new_posts_by_day = ( - Post.objects.filter(created_at__date__gte=start_dt.date(), created_at__date__lte=now.date()) + 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')) ) @@ -101,8 +138,6 @@ def stats_dashboard(request): 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() @@ -135,16 +170,8 @@ def stats_dashboard(request): # 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} - days = [] - values = [] - d = start_dt.date() - while d <= now.date(): - key = str(d) - days.append(key) - values.append(counts.get(key, 0)) - d += timezone.timedelta(days=1) - return days, values + # 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) @@ -158,8 +185,8 @@ def stats_dashboard(request): context = { 'period_options': period_options, 'p': p, - 'start_date': start_dt.date(), - 'end_date': now.date(), + 'start_date': period_start_date, + 'end_date': period_end_date, # KPI 'kpi': { @@ -206,19 +233,11 @@ def stats_dashboard(request): @cache_page(60 * 15) def stats_charts(request): """Page dédiée aux graphiques (réservée superadmins).""" - # Période + # Période (utilise les mêmes helpers que le dashboard pour harmonisation) period_options = [7, 30, 90, 180] - try: - p = int(request.GET.get('p', 30)) - except ValueError: - p = 30 - if p not in period_options: - p = 30 - - now = timezone.now() - start_dt = now - timezone.timedelta(days=p-1) - period_start_date = start_dt.date() - period_end_date = now.date() + 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 = ( @@ -255,8 +274,8 @@ def stats_charts(request): d += timezone.timedelta(days=1) return days, values - labels, visitors_series = build_series_dict(visits_qs, date_key='date') - _, conversions_series = build_series_dict(conversions_qs, date_key='day') + 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) diff --git a/templates/home/stats_charts.html b/templates/home/stats_charts.html index 159bdcd..15f4972 100644 --- a/templates/home/stats_charts.html +++ b/templates/home/stats_charts.html @@ -4,32 +4,14 @@ {% block title %}- Stats · Graphiques{% endblock %} {% block extra_head %} - - + {% include "partials/_stats_head.html" %} {% endblock %} {% block content %}

    Graphiques statistiques

    - -
    - - - Du {{ start_date }} au {{ end_date }} -
    -
    Mise en cache 15 minutes
    - + {% include "partials/_stats_toolbar.html" %}
    diff --git a/templates/home/stats_dashboard.html b/templates/home/stats_dashboard.html index 704ada6..8cd5325 100644 --- a/templates/home/stats_dashboard.html +++ b/templates/home/stats_dashboard.html @@ -4,17 +4,7 @@ {% block title %}- Tableau de bord{% endblock %} {% block extra_head %} - - + {% include "partials/_stats_head.html" %} {% endblock %} {% block content %} @@ -24,18 +14,7 @@ → Voir la page de graphiques

    -
    -
    - - - Du {{ start_date }} au {{ end_date }} -
    -
    Mise en cache 15 minutes
    -
    + {% include "partials/_stats_toolbar.html" %}
    From 38e4ce25625c7674e5f4752c18b67b1dc0675038 Mon Sep 17 00:00:00 2001 From: mrtoine Date: Tue, 16 Dec 2025 15:16:18 +0100 Subject: [PATCH 27/38] Ajout des partials `_stats_head.html` et `_stats_toolbar.html` pour harmoniser les styles et outils des pages de statistiques. --- templates/partials/_stats_head.html | 65 ++++++++++++++++++++++++++ templates/partials/_stats_toolbar.html | 15 ++++++ 2 files changed, 80 insertions(+) create mode 100644 templates/partials/_stats_head.html create mode 100644 templates/partials/_stats_toolbar.html diff --git a/templates/partials/_stats_head.html b/templates/partials/_stats_head.html new file mode 100644 index 0000000..1eba9b1 --- /dev/null +++ b/templates/partials/_stats_head.html @@ -0,0 +1,65 @@ +{# Partials: Shared head for stats pages (Chart.js + unified styles) #} + + + + diff --git a/templates/partials/_stats_toolbar.html b/templates/partials/_stats_toolbar.html new file mode 100644 index 0000000..6241f0a --- /dev/null +++ b/templates/partials/_stats_toolbar.html @@ -0,0 +1,15 @@ +{# Partials: Shared period toolbar for stats pages #} +
    +
    + + + Du {{ start_date }} au {{ end_date }} +
    +
    Mise en cache 15 minutes
    + {% block stats_toolbar_extra %}{% endblock %} + {# Keep block for optional extensions on specific pages #} +
    From acd9f42cea101fc6dbd0886503c820183e8b5828 Mon Sep 17 00:00:00 2001 From: mrtoine Date: Wed, 17 Dec 2025 11:19:27 +0100 Subject: [PATCH 28/38] =?UTF-8?q?Ajout=20du=20champ=20`receive=5Femails=5F?= =?UTF-8?q?active`=20dans=20`SiteSettings`,=20mise=20=C3=A0=20jour=20de=20?= =?UTF-8?q?l'admin,=20ajout=20d'un=20signal=20pour=20les=20notifications?= =?UTF-8?q?=20par=20email=20sur=20les=20commentaires,=20et=20am=C3=A9liora?= =?UTF-8?q?tion=20des=20`context=5Fprocessors`.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- core/admin.py | 2 +- core/apps.py | 4 +++- .../0005_sitesettings_receive_emails_active.py | 18 ++++++++++++++++++ core/models.py | 1 + courses/context_processors.py | 7 +++++-- courses/signals.py | 12 ++++++++++++ 6 files changed, 40 insertions(+), 4 deletions(-) create mode 100644 core/migrations/0005_sitesettings_receive_emails_active.py create mode 100644 courses/signals.py diff --git a/core/admin.py b/core/admin.py index a9baff2..90b60d0 100644 --- a/core/admin.py +++ b/core/admin.py @@ -14,7 +14,7 @@ class SiteSettingsAdmin(admin.ModelAdmin): # Petite astuce visuelle pour l'admin fieldsets = ( ('Général', { - 'fields': ('site_name', 'site_logo') + 'fields': ('site_name', 'site_logo', 'receive_emails_active') }), ('Réseaux Sociaux', { 'fields': ('facebook_url', 'twitter_url', 'youtube_url'), diff --git a/core/apps.py b/core/apps.py index 26f78a8..a2ee61b 100644 --- a/core/apps.py +++ b/core/apps.py @@ -1,5 +1,7 @@ from django.apps import AppConfig - class CoreConfig(AppConfig): name = 'core' + + def ready(self): + import courses.signals \ No newline at end of file diff --git a/core/migrations/0005_sitesettings_receive_emails_active.py b/core/migrations/0005_sitesettings_receive_emails_active.py new file mode 100644 index 0000000..81c0441 --- /dev/null +++ b/core/migrations/0005_sitesettings_receive_emails_active.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0 on 2025-12-17 09:36 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0004_visit'), + ] + + operations = [ + migrations.AddField( + model_name='sitesettings', + name='receive_emails_active', + field=models.BooleanField(default=True), + ), + ] diff --git a/core/models.py b/core/models.py index 63d9b18..297916d 100644 --- a/core/models.py +++ b/core/models.py @@ -6,6 +6,7 @@ class SiteSettings(models.Model): site_name = models.CharField(max_length=200, default="Mon Super Site") site_logo = models.ImageField(upload_to='settings/', blank=True) contact_email = models.EmailField(blank=True) + receive_emails_active = models.BooleanField(default=True) # Réseaux sociaux facebook_url = models.URLField(blank=True) diff --git a/courses/context_processors.py b/courses/context_processors.py index 2a4772e..4a22831 100644 --- a/courses/context_processors.py +++ b/courses/context_processors.py @@ -1,5 +1,8 @@ -from .models import Course +from .models import Course, Comment def course_list(request): courses = Course.objects.all() - return {'courses': courses} \ No newline at end of file + return {'courses': courses} + +def courses_comments(request): + return {'comments_count': Comment.objects.all()} \ No newline at end of file diff --git a/courses/signals.py b/courses/signals.py new file mode 100644 index 0000000..0258ca7 --- /dev/null +++ b/courses/signals.py @@ -0,0 +1,12 @@ +from django.db.models.signals import post_save +from django.dispatch import receiver +from django.core.mail import send_mail +from .models import Comment +from core.models import SiteSettings + +@receiver(post_save, sender=Comment) +def send_email_notification(sender, instance, created, **kwargs): + if created and SiteSettings.objects.first().receive_emails_active: + subject = f"Nouveau commentaire sur la leçon - {instance.lesson.name} du cours {instance.lesson.module.course.name}" + message = f"Le commentaire suivant à été envoyé par {instance.user}:\n{instance.content}" + send_mail(subject, message, "infos@partirdezero.com", ['anthony.violet@outlook.be'], fail_silently=False) From 536f4e303ff325654513e8e5478517041e6fa147 Mon Sep 17 00:00:00 2001 From: mrtoine Date: Wed, 17 Dec 2025 12:48:05 +0100 Subject: [PATCH 29/38] =?UTF-8?q?Ajout=20du=20mode=20maintenance=20avec=20?= =?UTF-8?q?mod=C3=A8le,=20vues,=20URL,=20contexte,=20et=20int=C3=A9gration?= =?UTF-8?q?=20des=20templates.=20Ajout=20de=20nouvelles=20fonctionnalit?= =?UTF-8?q?=C3=A9s=20c=C3=B4t=C3=A9=20client,=20comme=20le=20basculement?= =?UTF-8?q?=20de=20th=C3=A8me=20et=20les=20interactions=20de=20navigation?= =?UTF-8?q?=20mobile.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- core/admin.py | 6 +- core/context_processor.py | 13 +++- core/migrations/0006_maintenance.py | 23 +++++++ core/migrations/0007_maintenance_name.py | 18 +++++ core/models.py | 6 ++ core/urls.py | 8 +++ core/views.py | 19 +++++- devart/settings.py | 1 + devart/urls.py | 4 +- staticfiles/js/functions.js | 84 +++++++++++++++++++++++- templates/layout.html | 46 +++++++------ templates/maintenance.html | 26 ++++++++ 12 files changed, 227 insertions(+), 27 deletions(-) create mode 100644 core/migrations/0006_maintenance.py create mode 100644 core/migrations/0007_maintenance_name.py create mode 100644 core/urls.py create mode 100644 templates/maintenance.html diff --git a/core/admin.py b/core/admin.py index 90b60d0..13f115b 100644 --- a/core/admin.py +++ b/core/admin.py @@ -1,5 +1,5 @@ from django.contrib import admin -from .models import SiteSettings, Visit +from .models import SiteSettings, Visit, Maintenance @admin.register(SiteSettings) class SiteSettingsAdmin(admin.ModelAdmin): @@ -28,6 +28,10 @@ class SiteSettingsAdmin(admin.ModelAdmin): }), ) +@admin.register(Maintenance) +class MaintenanceAdmin(admin.ModelAdmin): + list_display = ("name","start_date", "end_date") + @admin.register(Visit) class VisitAdmin(admin.ModelAdmin): diff --git a/core/context_processor.py b/core/context_processor.py index a1410e3..eccaebf 100644 --- a/core/context_processor.py +++ b/core/context_processor.py @@ -1,5 +1,14 @@ -from .models import SiteSettings +from django.utils.timesince import timesince + +from .models import SiteSettings, Maintenance def site_settings(request): # On récupère le premier objet, ou None s'il n'existe pas encore - return {'settings': SiteSettings.objects.first()} \ No newline at end of file + return {'settings': SiteSettings.objects.first()} + +def site_maintenance(request): + last = Maintenance.objects.last() + start = last.start_date if last else None + end = last.end_date if last else None + delay = timesince(start, end) if start and end else None + return {'maintenance': Maintenance.objects.last(), 'delay': delay} \ No newline at end of file diff --git a/core/migrations/0006_maintenance.py b/core/migrations/0006_maintenance.py new file mode 100644 index 0000000..1cac83f --- /dev/null +++ b/core/migrations/0006_maintenance.py @@ -0,0 +1,23 @@ +# Generated by Django 6.0 on 2025-12-17 10:25 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0005_sitesettings_receive_emails_active'), + ] + + operations = [ + migrations.CreateModel( + name='Maintenance', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('is_active', models.BooleanField(default=False)), + ('message', models.TextField(blank=True)), + ('start_date', models.DateTimeField(blank=True, null=True)), + ('end_date', models.DateTimeField(blank=True, null=True)), + ], + ), + ] diff --git a/core/migrations/0007_maintenance_name.py b/core/migrations/0007_maintenance_name.py new file mode 100644 index 0000000..eef8f59 --- /dev/null +++ b/core/migrations/0007_maintenance_name.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0 on 2025-12-17 10:26 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0006_maintenance'), + ] + + operations = [ + migrations.AddField( + model_name='maintenance', + name='name', + field=models.CharField(blank=True, max_length=200), + ), + ] diff --git a/core/models.py b/core/models.py index 297916d..c28ae82 100644 --- a/core/models.py +++ b/core/models.py @@ -35,6 +35,12 @@ class SiteSettings(models.Model): verbose_name = "Réglages du site" verbose_name_plural = "Réglages du site" +class Maintenance(models.Model): + is_active = models.BooleanField(default=False) + name = models.CharField(max_length=200, blank=True) + message = models.TextField(blank=True) + start_date = models.DateTimeField(blank=True, null=True) + end_date = models.DateTimeField(blank=True, null=True) class Visit(models.Model): """Enregistrement simplifié des visites (agrégées par jour et visiteur). diff --git a/core/urls.py b/core/urls.py new file mode 100644 index 0000000..abf8ffc --- /dev/null +++ b/core/urls.py @@ -0,0 +1,8 @@ +from django.urls import path +from . import views + +urlpatterns = [ + path('update_database', views.update_database, name='update_database'), + path('clear_cache', views.clear_cache, name='clear_cache'), + path('regen_static_files', views.regen_static_files, name='regen_static_files') +] \ No newline at end of file diff --git a/core/views.py b/core/views.py index 91ea44a..3d8b995 100644 --- a/core/views.py +++ b/core/views.py @@ -1,3 +1,20 @@ from django.shortcuts import render +from django.core.management import call_command +from django.core.cache import cache +import subprocess -# Create your views here. +def update_database(request): + call_command('makemigrations') + call_command('migrate') + message = "La base de données à bien été mise à jour !" + return render(request, 'home.html', {'message': message}) + +def clear_cache(request): + cache.clear() + message = "Le cache à bien été effacé !" + return render(request, 'home.html', {'message': message}) + +def regen_static_files(request): + call_command('collectstatic', '--noinput') + message = "Les fichiers statics ont bien été générés !" + return render(request, 'home.html', {'message': message}) \ No newline at end of file diff --git a/devart/settings.py b/devart/settings.py index f846daa..db43f75 100644 --- a/devart/settings.py +++ b/devart/settings.py @@ -84,6 +84,7 @@ TEMPLATES = [ 'devart.context_processor.app_version', 'core.context_processor.site_settings', + 'core.context_processor.site_maintenance', 'courses.context_processors.course_list', 'blog.context_processor.posts_list', ], diff --git a/devart/urls.py b/devart/urls.py index 17cb4e7..036a891 100644 --- a/devart/urls.py +++ b/devart/urls.py @@ -22,12 +22,13 @@ from django.http import HttpResponse from devart.sitemap import CourseSitemap, StaticViewSitemap from django.contrib.sitemaps.views import sitemap -# La vue pour le robots.txt def robots_txt(request): lines = [ "User-agent: *", "Disallow: /admin/", "Disallow: /users/", + "Disallow: /maintenance/", + "Disallow: /core/", "Allow: /", "Sitemap: https://partirdezero.com/sitemap.xml", # On indique déjà où sera le plan ] @@ -39,6 +40,7 @@ sitemaps_dict = { } urlpatterns = [ + path('core/', include('core.urls')), path('admin/', admin.site.urls), path('', include('home.urls')), path('courses/', include('courses.urls')), diff --git a/staticfiles/js/functions.js b/staticfiles/js/functions.js index d5195f9..500562a 100644 --- a/staticfiles/js/functions.js +++ b/staticfiles/js/functions.js @@ -12,4 +12,86 @@ function show(id) { htmlChange.id = `show-${id}`; let buttonChange = document.getElementById(id); buttonChange.onclick = function() { hide(id); }; -} \ No newline at end of file +} + +// Fonction pour supprimer le message d'alerte après 5 secondes +document.addEventListener('DOMContentLoaded', function() { + let messages = document.querySelector('.flash_messages') + if (messages) { + setTimeout(function() { + messages.style.opacity = 0; + setTimeout(function() { + messages.style.display = 'none'; + }, 1000); + }, 5000); + } + + // Theme toggle setup + var toggle = document.getElementById('themeToggle'); + var themeLink = document.getElementById('theme-css'); + function applyTheme(theme) { + document.documentElement.setAttribute('data-theme', theme); + if (theme === 'light') { + if (themeLink) themeLink.href = themeLink.href.replace('colors_dark.css', 'colors_light.css'); + } else { + if (themeLink) themeLink.href = themeLink.href.replace('colors_light.css', 'colors_dark.css'); + } + var icon = toggle && toggle.querySelector('i'); + if (icon) { + icon.classList.remove('fa-sun','fa-moon'); + icon.classList.add(theme === 'light' ? 'fa-moon' : 'fa-sun'); + } + if (toggle) { + toggle.setAttribute('aria-pressed', theme === 'dark' ? 'true' : 'false'); + toggle.setAttribute('aria-label', theme === 'light' ? 'Activer le thème sombre' : 'Activer le thème clair'); + toggle.title = toggle.getAttribute('aria-label'); + } + } + try { + var current = document.documentElement.getAttribute('data-theme') || 'dark'; + applyTheme(current); + if (toggle) { + toggle.addEventListener('click', function() { + var next = (document.documentElement.getAttribute('data-theme') === 'light') ? 'dark' : 'light'; + localStorage.setItem('pdz-theme', next); + applyTheme(next); + }); + } + } catch(e) {} + + // Mobile nav toggle + try { + var navToggle = document.getElementById('navToggle'); + var primaryNav = document.getElementById('primaryNav'); + if (navToggle && primaryNav) { + function setExpanded(expanded) { + navToggle.setAttribute('aria-expanded', expanded ? 'true' : 'false'); + navToggle.setAttribute('aria-label', expanded ? 'Fermer le menu' : 'Ouvrir le menu'); + var icon = navToggle.querySelector('i'); + if (icon) { + icon.classList.remove('fa-bars','fa-xmark'); + icon.classList.add(expanded ? 'fa-xmark' : 'fa-bars'); + } + primaryNav.classList.toggle('is-open', expanded); + } + + navToggle.addEventListener('click', function() { + var expanded = navToggle.getAttribute('aria-expanded') === 'true'; + setExpanded(!expanded); + }); + + // Close menu when a link is clicked (on small screens) + primaryNav.addEventListener('click', function(e) { + var target = e.target; + if (target.tagName === 'A' || target.closest('a')) { + setExpanded(false); + } + }); + + // Close on Escape + document.addEventListener('keydown', function(e) { + if (e.key === 'Escape') setExpanded(false); + }); + } + } catch(e) {} +}); \ No newline at end of file diff --git a/templates/layout.html b/templates/layout.html index 2a39086..1d70ca8 100644 --- a/templates/layout.html +++ b/templates/layout.html @@ -84,29 +84,33 @@ - {% now "n" as month %} - {% if month == '12' %} - - - {% endif %} - {% block header %} - {% include "partials/_header.html" %} - {% endblock %} - -
    - {% if messages %} -
      - {% for message in messages %} - {{ message }} - {% endfor %} -
    + {% if maintenance.is_active == True %} + {% include "maintenance.html" %} + {% else %} + {% now "n" as month %} + {% if month == '12' %} + + {% endif %} + {% block header %} + {% include "partials/_header.html" %} + {% endblock %} - {% block content %}{% endblock %} -
    +
    + {% if messages %} +
      + {% for message in messages %} + {{ message }} + {% endfor %} +
    + {% endif %} - {% block footer %} - {% include "partials/_footer.html" %} - {% endblock %} + {% block content %}{% endblock %} +
    + + {% block footer %} + {% include "partials/_footer.html" %} + {% endblock %} + {% endif %} \ No newline at end of file diff --git a/templates/maintenance.html b/templates/maintenance.html new file mode 100644 index 0000000..52f40a9 --- /dev/null +++ b/templates/maintenance.html @@ -0,0 +1,26 @@ +{% load comment_format %} +
    +

    Maintenance : {{ maintenance.name }}

    + {{ maintenance.message|comment_markdown }} +
    Durée estimée : {{ delay }}
    +
    Début de la maintenance : {{ maintenance.start_date }}
    +
    Fin de la maintenance estimé : {{ maintenance.end_date }}
    + + {% if message %} +

    {{ message }}

    + {% endif %} + + {% if user.is_superuser %} +
    + + {% endif %} +
    \ No newline at end of file From 1685fe0a6d6a3fea162a76faefb368a91da17ccf Mon Sep 17 00:00:00 2001 From: mrtoine Date: Wed, 17 Dec 2025 12:48:51 +0100 Subject: [PATCH 30/38] =?UTF-8?q?Mise=20=C3=A0=20jour=20de=20la=20version?= =?UTF-8?q?=20applicative=20dans=20`VERSION.txt`.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- VERSION.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/VERSION.txt b/VERSION.txt index 43b4937..4654951 100644 --- a/VERSION.txt +++ b/VERSION.txt @@ -1 +1 @@ -1.3.8 (7cf0496) \ No newline at end of file +1.4.0 (536f4e3) \ No newline at end of file From e9754c2713e49d24f69e8b1bc9d9123201bbe026 Mon Sep 17 00:00:00 2001 From: mrtoine Date: Wed, 17 Dec 2025 14:18:48 +0100 Subject: [PATCH 31/38] =?UTF-8?q?Ajout=20de=20la=20commande=20de=20red?= =?UTF-8?q?=C3=A9marrage=20du=20serveur=20dans=20le=20template=20maintenan?= =?UTF-8?q?ce,=20d'une=20URL=20d=C3=A9di=C3=A9e=20=C3=A0=20`reload=5Fserve?= =?UTF-8?q?r`,=20et=20des=20styles=20associ=C3=A9s.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- core/urls.py | 3 ++- core/views.py | 5 ++++- static/css/app.css | 18 ++++++++++++++++++ templates/maintenance.html | 4 +++- 4 files changed, 27 insertions(+), 3 deletions(-) diff --git a/core/urls.py b/core/urls.py index abf8ffc..64f9484 100644 --- a/core/urls.py +++ b/core/urls.py @@ -4,5 +4,6 @@ from . import views urlpatterns = [ path('update_database', views.update_database, name='update_database'), path('clear_cache', views.clear_cache, name='clear_cache'), - path('regen_static_files', views.regen_static_files, name='regen_static_files') + path('regen_static_files', views.regen_static_files, name='regen_static_files'), + path('reload_server', views.reload_server, name='reload_server') ] \ No newline at end of file diff --git a/core/views.py b/core/views.py index 3d8b995..204f4f0 100644 --- a/core/views.py +++ b/core/views.py @@ -17,4 +17,7 @@ def clear_cache(request): def regen_static_files(request): call_command('collectstatic', '--noinput') message = "Les fichiers statics ont bien été générés !" - return render(request, 'home.html', {'message': message}) \ No newline at end of file + return render(request, 'home.html', {'message': message}) + +def reload_server(request): + pass \ No newline at end of file diff --git a/static/css/app.css b/static/css/app.css index a4ddad5..2531ab5 100644 --- a/static/css/app.css +++ b/static/css/app.css @@ -2672,4 +2672,22 @@ ul.flash_messages li.error { ul.flash_messages li.success { background-color: var(--success); color: var(--success-contrast); +} + +.message-warning { + color: var(--neutral-900); + background: var(--neutral-200); + border: 1px solid var(--warning); + padding: 10px; + border-radius: 5px; + margin-bottom: 10px; +} + +.message-info { + color: var(--neutral-900); + background: var(--neutral-200); + border: 1px solid var(--primary); + padding: 10px; + border-radius: 5px; + margin-bottom: 10px; } \ No newline at end of file diff --git a/templates/maintenance.html b/templates/maintenance.html index 52f40a9..77a3aa7 100644 --- a/templates/maintenance.html +++ b/templates/maintenance.html @@ -19,8 +19,10 @@
  • Effacer le cache
  • Régénérer les fichiers static
  • -
  • Panel Admin Redemarrer le serveur Django
  • +
  • Panel Admin
  • +
    Commande de redemarrage serveur : kill -HUP $(cat /var/www/vhosts/partirdezero.com/httpdocs/run/gunicorn.pid)
    + {% endif %}
    \ No newline at end of file From 1354568495115c1f67a86b8a00babf88b4072f03 Mon Sep 17 00:00:00 2001 From: mrtoine Date: Wed, 17 Dec 2025 14:19:21 +0100 Subject: [PATCH 32/38] =?UTF-8?q?Mise=20=C3=A0=20jour=20de=20la=20version?= =?UTF-8?q?=20applicative=20dans=20`VERSION.txt`.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- VERSION.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/VERSION.txt b/VERSION.txt index 4654951..e3a8aa8 100644 --- a/VERSION.txt +++ b/VERSION.txt @@ -1 +1 @@ -1.4.0 (536f4e3) \ No newline at end of file +1.4.1 (e9754c2) \ No newline at end of file From 2ec4a5c065f4aefac119a31de2d10876a041a092 Mon Sep 17 00:00:00 2001 From: mrtoine Date: Thu, 18 Dec 2025 10:28:28 +0100 Subject: [PATCH 33/38] =?UTF-8?q?Ajout=20de=20l'application=20`discord=5Fi?= =?UTF-8?q?ntegration`=20avec=20mod=C3=A8les,=20migrations,=20logique=20d'?= =?UTF-8?q?annonces=20et=20gestion=20des=20r=C3=B4les=20dans=20Discord.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- blog/models.py | 6 +- courses/apps.py | 1 - devart/settings.py | 2 + discord_integration/__init__.py | 0 discord_integration/admin.py | 3 + discord_integration/apps.py | 8 +++ discord_integration/core/__init__.py | 0 discord_integration/core/announces_logic.py | 58 +++++++++++++++++++ discord_integration/core/main_bot.py | 49 ++++++++++++++++ discord_integration/core/role_logic.py | 39 +++++++++++++ .../migrations/0001_initial.py | 26 +++++++++ discord_integration/migrations/__init__.py | 0 discord_integration/models.py | 13 +++++ discord_integration/signals.py | 26 +++++++++ discord_integration/tests.py | 3 + discord_integration/views.py | 3 + templates/partials/_footer.html | 1 + templates/partials/_header.html | 7 +-- 18 files changed, 239 insertions(+), 6 deletions(-) create mode 100644 discord_integration/__init__.py create mode 100644 discord_integration/admin.py create mode 100644 discord_integration/apps.py create mode 100644 discord_integration/core/__init__.py create mode 100644 discord_integration/core/announces_logic.py create mode 100644 discord_integration/core/main_bot.py create mode 100644 discord_integration/core/role_logic.py create mode 100644 discord_integration/migrations/0001_initial.py create mode 100644 discord_integration/migrations/__init__.py create mode 100644 discord_integration/models.py create mode 100644 discord_integration/signals.py create mode 100644 discord_integration/tests.py create mode 100644 discord_integration/views.py diff --git a/blog/models.py b/blog/models.py index 387d847..5d0d68c 100644 --- a/blog/models.py +++ b/blog/models.py @@ -1,4 +1,5 @@ from django.db import models +from django.urls import reverse class Post(models.Model): name = models.CharField(max_length=200) @@ -15,4 +16,7 @@ class Post(models.Model): verbose_name_plural = "Articles" def __str__(self): - return self.name \ No newline at end of file + return self.name + + def get_absolute_url(self): + return reverse('blog:post_detail', kwargs={'slug': self.slug}) \ No newline at end of file diff --git a/courses/apps.py b/courses/apps.py index 89f1ba2..9a0a0cd 100644 --- a/courses/apps.py +++ b/courses/apps.py @@ -1,6 +1,5 @@ from django.apps import AppConfig - class CoursesConfig(AppConfig): default_auto_field = 'django.db.models.BigAutoField' name = 'courses' \ No newline at end of file diff --git a/devart/settings.py b/devart/settings.py index db43f75..f2dab3b 100644 --- a/devart/settings.py +++ b/devart/settings.py @@ -55,6 +55,8 @@ INSTALLED_APPS = [ 'users', 'progression', 'blog', + + 'discord_integration.apps.DiscordIntegrationConfig', ] MIDDLEWARE = [ diff --git a/discord_integration/__init__.py b/discord_integration/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/discord_integration/admin.py b/discord_integration/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/discord_integration/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/discord_integration/apps.py b/discord_integration/apps.py new file mode 100644 index 0000000..56fe46c --- /dev/null +++ b/discord_integration/apps.py @@ -0,0 +1,8 @@ +from django.apps import AppConfig + + +class DiscordIntegrationConfig(AppConfig): + name = 'discord_integration' + + def ready(self): + import discord_integration.signals diff --git a/discord_integration/core/__init__.py b/discord_integration/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/discord_integration/core/announces_logic.py b/discord_integration/core/announces_logic.py new file mode 100644 index 0000000..03e8939 --- /dev/null +++ b/discord_integration/core/announces_logic.py @@ -0,0 +1,58 @@ +from asgiref.sync import sync_to_async +import discord +from discord.ext import tasks +from discord_integration.models import DiscordNotification + +def get_pending_notifications(): + return list(DiscordNotification.objects.filter(is_announced=False)) + +def mark_as_done(notif): + notif.is_announced = True + notif.save() + +def process_notifications(): + # Cette fonction fait tout le travail SQL "interdit" en mode async + notifs = list(DiscordNotification.objects.filter(is_announced=False)) + results = [] + for n in notifs: + # Ici, on peut toucher à content_object car on est en mode "sync" ! + obj = n.content_object + if obj: + # 1. On cherche la description, sinon le contenu, sinon rien + teaser = getattr(obj, 'description', getattr(obj, 'content', "")) + + # 2. Sécurité : On coupe à 3000 caractères pour éviter les erreurs Discord + if len(teaser) > 3000: + teaser = teaser[:2997] + "..." + + results.append({ + 'notif_model': n, + 'title': getattr(obj, 'title', getattr(obj, 'name', str(obj))), + 'url': obj.get_absolute_url() if hasattr(obj, 'get_absolute_url') else "#", + 'summary': teaser + }) + + return results + +@tasks.loop(seconds=5.0) +async def check_announcements(client, channel_id): + print("Checking announcements...") + announcements = await sync_to_async(process_notifications)() + + for data in announcements: + title = data['title'] + link = data['url'] + notif = data['notif_model'] + summary = data['summary'] + + embed = discord.Embed( + title = f"📣 Nouveau contenu : {title}", + url = f"https://partirdezero.com{link}", + description = summary, + color=discord.Color.blue() + ) + + channel_id = client.get_channel(channel_id) + if channel_id: + await channel_id.send(embed=embed) + await sync_to_async(mark_as_done)(notif) diff --git a/discord_integration/core/main_bot.py b/discord_integration/core/main_bot.py new file mode 100644 index 0000000..aae766e --- /dev/null +++ b/discord_integration/core/main_bot.py @@ -0,0 +1,49 @@ +import discord +import django +import os, sys +from discord.ext import commands + +# On import django pour communiquer avec +BASE_DIR = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +if BASE_DIR not in sys.path: + sys.path.insert(0, BASE_DIR) +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'devart.settings') +django.setup() + +# Import des fonctions +from role_logic import check_role_reaction +from announces_logic import check_announcements + +# CONFIGURATION +TOKEN = 'MTQ1MDkyNzQ5NzQ3Nzc1MDg1NA.GmkYxN.YHWXYUIav51yriV_9EotmtUO-cQqdVFLkkb6Do' +MESSAGE_ID = 1450928822156263505 # L'ID du message des règles (clic droit > Copier l'identifiant) +ROLE_ID = 1450920002868875435 # L'ID du rôle "Membres" +ANNOUNCEMENT_CHANNEL_ID = 1450912559774306346 +EMOJI_VALIDATION = "✅" + +# LES INTENTS (PERMISSIONS DU BOT) +intents = discord.Intents.default() +intents.members = True # Important pour pouvoir donner des rôles +intents.message_content = True + +client = discord.Client(intents=intents) + +@client.event +async def on_ready(): + print(f'✅ Bot connecté : {client.user}') + + if not check_announcements.is_running(): + check_announcements.start(client, ANNOUNCEMENT_CHANNEL_ID) + +@client.event +async def on_raw_reaction_add(payload): + # On envoie tout le nécessaire à notre fonction dans role_logic.py + await check_role_reaction( + payload, + client, + MESSAGE_ID, + ROLE_ID, + EMOJI_VALIDATION + ) + +client.run(TOKEN) diff --git a/discord_integration/core/role_logic.py b/discord_integration/core/role_logic.py new file mode 100644 index 0000000..8dd2dbc --- /dev/null +++ b/discord_integration/core/role_logic.py @@ -0,0 +1,39 @@ +import discord + + +async def check_role_reaction(payload, client, target_message_id, target_role_id, target_emoji): + # 1. On vérifie si c'est le bon message + if payload.message_id != target_message_id: + return # On ignore si ce n'est pas le bon message + + # 2. On vérifie si c'est le bon emoji + if str(payload.emoji) == target_emoji: + guild = client.get_guild(payload.guild_id) + if guild is None: + print("Erreur: Impossible de trouver le serveur (Guild is None).") + return + + member = guild.get_member(payload.user_id) + if member is None: + print("Erreur: Impossible de trouver le membre (Member is None).") + return + + role = guild.get_role(target_role_id) + if role is None: + print("Erreur : Le role n'existe pas.") + return + + try: + await member.add_roles(role) + print(f"🎉 SUCCÈS : Rôle donné à {member.name} !") + try: + await member.send("Bienvenue ! Tu as accès aux salons.") + except: + print("Note: MP bloqués par l'utilisateur.") + except discord.Forbidden: + print("⛔ ERREUR PERMISSION : Je n'ai pas le droit de donner ce rôle !") + print( + "👉 SOLUTION : Va dans Paramètres Serveur > Rôles. Glisse le rôle 'PartirDeZero Bot' AU-DESSUS du rôle 'Membres'.") + + except Exception as e: + print(f"❌ Erreur inconnue : {e}") \ No newline at end of file diff --git a/discord_integration/migrations/0001_initial.py b/discord_integration/migrations/0001_initial.py new file mode 100644 index 0000000..6c269a9 --- /dev/null +++ b/discord_integration/migrations/0001_initial.py @@ -0,0 +1,26 @@ +# Generated by Django 6.0 on 2025-12-18 08:23 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('contenttypes', '0002_remove_content_type_name'), + ] + + operations = [ + migrations.CreateModel( + name='DiscordNotification', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('object_id', models.PositiveIntegerField()), + ('is_announced', models.BooleanField(default=False)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype')), + ], + ), + ] diff --git a/discord_integration/migrations/__init__.py b/discord_integration/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/discord_integration/models.py b/discord_integration/models.py new file mode 100644 index 0000000..9906f9b --- /dev/null +++ b/discord_integration/models.py @@ -0,0 +1,13 @@ +from django.contrib.contenttypes.fields import GenericForeignKey +from django.db import models +from django.contrib.contenttypes.models import ContentType + +class DiscordNotification(models.Model): + content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE) + object_id = models.PositiveIntegerField() + content_object = GenericForeignKey('content_type', 'object_id') + is_announced = models.BooleanField(default=False) + created_at = models.DateTimeField(auto_now_add=True) + + def __str__(self): + return f"Annonces pour {self.content_object} ({'✅' if self.is_announced else '⏳'})" \ No newline at end of file diff --git a/discord_integration/signals.py b/discord_integration/signals.py new file mode 100644 index 0000000..c69abe4 --- /dev/null +++ b/discord_integration/signals.py @@ -0,0 +1,26 @@ +from django.db.models.signals import post_save +from django.dispatch import receiver +from .models import DiscordNotification +from blog.models import Post +from courses.models import Lesson, Course + +@receiver(post_save, sender="blog.Post") +def create_discord_notification_blog(sender, instance, created, **kwargs): + print(f"DEBUG : Signal capté ! Nouveau objet : {instance}") + if created: + DiscordNotification.objects.create(content_object=instance) + print("DEBUG : Notification enregistée en base de données") + +@receiver(post_save, sender="courses.Course") +def create_discord_notification_course(sender, instance, created, **kwargs): + print(f"DEBUG : Signal capté ! Nouveau objet : {instance}") + if created: + DiscordNotification.objects.create(content_object=instance) + print("DEBUG : Notification enregistée en base de données") + +@receiver(post_save, sender="courses.Lesson") +def create_discord_notification_lesson(sender, instance, created, **kwargs): + print(f"DEBUG : Signal capté ! Nouveau objet : {instance}") + if created: + DiscordNotification.objects.create(content_object=instance) + print("DEBUG : Notification enregistée en base de données") \ No newline at end of file diff --git a/discord_integration/tests.py b/discord_integration/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/discord_integration/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/discord_integration/views.py b/discord_integration/views.py new file mode 100644 index 0000000..91ea44a --- /dev/null +++ b/discord_integration/views.py @@ -0,0 +1,3 @@ +from django.shortcuts import render + +# Create your views here. diff --git a/templates/partials/_footer.html b/templates/partials/_footer.html index 934a5e3..42cc419 100644 --- a/templates/partials/_footer.html +++ b/templates/partials/_footer.html @@ -29,6 +29,7 @@ {% if settings.contact_email %}
  • Email
  • {% endif %} +
  • Communauté
  • diff --git a/templates/partials/_header.html b/templates/partials/_header.html index a6938aa..b4ffa39 100644 --- a/templates/partials/_header.html +++ b/templates/partials/_header.html @@ -45,11 +45,10 @@ {% endif %}
  • +
  • Discord
  • {% if user.is_authenticated and user.is_staff %} -
  • - Admin - Stats -
  • +
  • Admin
  • +
  • Stats
  • {% endif %}
  • {% if user.is_authenticated %} From afc26145350387ca961ff6715522bf12bf84de33 Mon Sep 17 00:00:00 2001 From: mrtoine Date: Thu, 18 Dec 2025 10:29:14 +0100 Subject: [PATCH 34/38] =?UTF-8?q?Mise=20=C3=A0=20jour=20de=20la=20version?= =?UTF-8?q?=20applicative=20dans=20`VERSION.txt`.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- VERSION.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/VERSION.txt b/VERSION.txt index e3a8aa8..0b51dff 100644 --- a/VERSION.txt +++ b/VERSION.txt @@ -1 +1 @@ -1.4.1 (e9754c2) \ No newline at end of file +1.5.0 (2ec4a5c) \ No newline at end of file From ad6600e4f66247a9ef75b2968cd2329af164c920 Mon Sep 17 00:00:00 2001 From: mrtoine Date: Thu, 18 Dec 2025 10:35:56 +0100 Subject: [PATCH 35/38] =?UTF-8?q?Mise=20=C3=A0=20jour=20du=20lien=20Discor?= =?UTF-8?q?d=20dans=20`=5Fheader.html`.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- templates/partials/_header.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/partials/_header.html b/templates/partials/_header.html index b4ffa39..4c3e694 100644 --- a/templates/partials/_header.html +++ b/templates/partials/_header.html @@ -45,7 +45,7 @@ {% endif %}
  • -
  • Discord
  • +
  • Discord
  • {% if user.is_authenticated and user.is_staff %}
  • Admin
  • Stats
  • From 3f90cfa3396e20f3169349587431de2ecc9a7406 Mon Sep 17 00:00:00 2001 From: mrtoine Date: Thu, 18 Dec 2025 14:38:31 +0100 Subject: [PATCH 36/38] =?UTF-8?q?Ajout=20des=20commandes=20de=20gestion=20?= =?UTF-8?q?du=20niveau,=20d'une=20logique=20XP,=20et=20de=20nouvelles=20fo?= =?UTF-8?q?nctionnalit=C3=A9s=20bot=20dans=20`discord=5Fintegration`.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- discord_integration/core/BotClass.py | 14 +++++ discord_integration/core/announces_logic.py | 1 - discord_integration/core/enums.py | 16 +++++ discord_integration/core/level_logic.py | 70 +++++++++++++++++++++ discord_integration/core/main_bot.py | 28 ++++++++- discord_integration/core/random_phrase.py | 32 ++++++++++ discord_integration/models.py | 9 ++- 7 files changed, 166 insertions(+), 4 deletions(-) create mode 100644 discord_integration/core/BotClass.py create mode 100644 discord_integration/core/enums.py create mode 100644 discord_integration/core/level_logic.py create mode 100644 discord_integration/core/random_phrase.py diff --git a/discord_integration/core/BotClass.py b/discord_integration/core/BotClass.py new file mode 100644 index 0000000..ac9da47 --- /dev/null +++ b/discord_integration/core/BotClass.py @@ -0,0 +1,14 @@ +import discord +from discord import app_commands + +intents = discord.Intents.default() +intents.members = True +intents.message_content = True + +class Bot(discord.Client): + def __init__(self, *, intents: discord.Intents): + super().__init__(intents=intents) + self.tree = app_commands.CommandTree(self) + + async def setup_hook(self): + await self.tree.sync() \ No newline at end of file diff --git a/discord_integration/core/announces_logic.py b/discord_integration/core/announces_logic.py index 03e8939..6781202 100644 --- a/discord_integration/core/announces_logic.py +++ b/discord_integration/core/announces_logic.py @@ -36,7 +36,6 @@ def process_notifications(): @tasks.loop(seconds=5.0) async def check_announcements(client, channel_id): - print("Checking announcements...") announcements = await sync_to_async(process_notifications)() for data in announcements: diff --git a/discord_integration/core/enums.py b/discord_integration/core/enums.py new file mode 100644 index 0000000..75c7ced --- /dev/null +++ b/discord_integration/core/enums.py @@ -0,0 +1,16 @@ +XP = { + "MESSAGE": 5 +} + +RANK = { + 1: "Nouveau membre", + 3: "Membre", + 7: "Habitué du comptoir", + 12: "Expert", + 18: "Chevalier du code", + 25: "Baron C#", + 35: "Lord Script", + 50: "Héros des architectures", + 75: "Vétéran", + 100: "Légende" +} \ No newline at end of file diff --git a/discord_integration/core/level_logic.py b/discord_integration/core/level_logic.py new file mode 100644 index 0000000..a3051c6 --- /dev/null +++ b/discord_integration/core/level_logic.py @@ -0,0 +1,70 @@ +from datetime import datetime, timezone + +from asgiref.sync import sync_to_async +import discord +from datetime import datetime +from discord_integration.models import DiscordLevel +from enums import XP, RANK +from discord import app_commands + +def get_user(id_discord): + user, created = DiscordLevel.objects.get_or_create(discord_id=id_discord) + return user, created + +def update_user_xp(user, xp_to_add): + leveled_up = False + + # 1. Mise à jour de l'XP et du temps + user.total_xp += xp_to_add + user.last_message = datetime.now(timezone.utc) + + # 2. Calcul du niveau théorique + calculated_level = int(0.5 + (0.25 + user.total_xp / 50)**0.5) + + # 3. Vérification du Level Up + if calculated_level > user.level: + user.level = calculated_level + leveled_up = True + + new_rank = RANK[1] + + for level_threshold, rank_name in RANK.items(): + if user.level >= level_threshold: + new_rank = rank_name + else: + break + + user.rank = new_rank + + user.save() + return user, leveled_up + +async def check_add_xp(message, client): + id_discord = message.author.id + username = message.author.name + + user_db, created = await sync_to_async(get_user)(id_discord) + + if not created and user_db.last_message: + delta = datetime.now(timezone.utc) - user_db.last_message + if delta.seconds < 6: + return + + user_db, leveled_up = await sync_to_async(update_user_xp)(user_db, XP["MESSAGE"]) + + if leveled_up: + # On crée un petit message sympa + await message.channel.send( + f"🎊 **LEVEL UP** 🎊\nBravo {message.author.mention}, tu passes **niveau {user_db.level}** ! " + f"On applaudit tous bien fort ! Clap Clap !!" + ) + else: + # Juste un petit log console pour toi + print(f"✨ XP ajouté pour {message.author.name} (Total: {user_db.total_xp})") + +# AJOUT DES COMMANDES /xp et /level +@app_commands.command(name="level", description="Permet de connaitre ton xp actuel") +async def get_xp(interaction: discord.Interaction): + user_id = interaction.user.id + user_db, _ = await sync_to_async(get_user)(user_id) + await interaction.response.send_message(f"{interaction.user.mention} : Tu as {user_db.total_xp} XP et tu es niveau {user_db.level} !\nTon rang est {user_db.rank} !") \ No newline at end of file diff --git a/discord_integration/core/main_bot.py b/discord_integration/core/main_bot.py index aae766e..6fa7d4c 100644 --- a/discord_integration/core/main_bot.py +++ b/discord_integration/core/main_bot.py @@ -1,6 +1,8 @@ import discord import django import os, sys + +import dotenv from discord.ext import commands # On import django pour communiquer avec @@ -13,9 +15,12 @@ django.setup() # Import des fonctions from role_logic import check_role_reaction from announces_logic import check_announcements +from random_phrase import get_random_phrase +from level_logic import check_add_xp, get_xp +import BotClass # CONFIGURATION -TOKEN = 'MTQ1MDkyNzQ5NzQ3Nzc1MDg1NA.GmkYxN.YHWXYUIav51yriV_9EotmtUO-cQqdVFLkkb6Do' +TOKEN = dotenv.get_key(BASE_DIR + '/.env', 'D_TOKEN') MESSAGE_ID = 1450928822156263505 # L'ID du message des règles (clic droit > Copier l'identifiant) ROLE_ID = 1450920002868875435 # L'ID du rôle "Membres" ANNOUNCEMENT_CHANNEL_ID = 1450912559774306346 @@ -26,15 +31,34 @@ intents = discord.Intents.default() intents.members = True # Important pour pouvoir donner des rôles intents.message_content = True -client = discord.Client(intents=intents) +client = BotClass.Bot(intents=intents) +client.tree.add_command(get_random_phrase) +client.tree.add_command(get_xp) @client.event async def on_ready(): print(f'✅ Bot connecté : {client.user}') + try: + synced = await client.tree.sync() + print(f"🌍 {len(synced)} commandes slash synchronisées !") + except Exception as e: + print(f"❌ Erreur de synchronisation : {e}") + if not check_announcements.is_running(): check_announcements.start(client, ANNOUNCEMENT_CHANNEL_ID) +@client.event +async def on_message(message): + if message.author == client.user: + return + if message.guild is None: + author = message.author + await message.channel.send("Bonjour !\nJe suis un bot destiné à tester les nouvelles fonctionnalités de Discord. Pour le moment, je suis qu'en lecture seule.") + else: + await check_add_xp(message, client) + + @client.event async def on_raw_reaction_add(payload): # On envoie tout le nécessaire à notre fonction dans role_logic.py diff --git a/discord_integration/core/random_phrase.py b/discord_integration/core/random_phrase.py new file mode 100644 index 0000000..e8cfea2 --- /dev/null +++ b/discord_integration/core/random_phrase.py @@ -0,0 +1,32 @@ +import discord +import random + +from discord import app_commands + +phrase = [ + "Yo !", + "Quoi de neuf ?", + "Je suis occupé à compter mes octets.", + "Vive la Belgique ! 🇧🇪", + "C’est pas un bug, c’est une fonctionnalité non documentée ! 🐛", + "Est-ce qu’on peut dire que mon code est une œuvre d’art ? Non ? Dommage.", + "Je ne plante pas, je fais une pause créative.", + "Quelqu’un a vu mon point-virgule ? Il a disparu depuis le dernier commit.", + "Je mangerais bien une mitraillette sauce andalouse, mais mon système digestif est en 404. 🍟", + "42. Voilà. Maintenant, pose-moi une vraie question.", + "On mange quoi ? Ah non, c'est vrai, je suis un robot... Tristesse infinie. 🤖", + "C'est écrit en Python, donc c'est forcément élégant, non ?", + "Un petit café ? Pour moi, une petite dose d'électricité suffira.", + "Je parie que tu n'as pas encore fait ton `git push` aujourd'hui. Je te surveille ! 👀", + "En Belgique, on n'a peut-être pas toujours du soleil, mais on a les meilleures frites ! 🇧🇪🍟", + "Il y a 10 types de personnes : celles qui comprennent le binaire, et les autres.", + "Mon processeur chauffe... soit je réfléchis trop, soit ton code est trop complexe !", + "Tout va bien, tant que personne ne touche au dossier `migrations` de Django...", + "Sais-tu pourquoi les développeurs détestent la nature ? Parce qu'il y a trop de bugs. 🌳", + "On n'est pas là pour trier des lentilles, une fois ! On code ou quoi ? 🇧🇪" +] + +@app_commands.command(name="random_phrase", description="Envoi une phrase aléatoire !") +async def get_random_phrase(interaction: discord.Interaction): + choice = random.choice(phrase) + await interaction.response.send_message(choice) \ No newline at end of file diff --git a/discord_integration/models.py b/discord_integration/models.py index 9906f9b..614a096 100644 --- a/discord_integration/models.py +++ b/discord_integration/models.py @@ -10,4 +10,11 @@ class DiscordNotification(models.Model): created_at = models.DateTimeField(auto_now_add=True) def __str__(self): - return f"Annonces pour {self.content_object} ({'✅' if self.is_announced else '⏳'})" \ No newline at end of file + return f"Annonces pour {self.content_object} ({'✅' if self.is_announced else '⏳'})" + +class DiscordLevel(models.Model): + discord_id = models.BigIntegerField() + total_xp = models.PositiveIntegerField(default=0) + level = models.PositiveIntegerField(default=1) + rank = models.TextField(default="Nouveau membre") + last_message = models.DateTimeField(auto_now_add=True) \ No newline at end of file From 2a6f670361004b549204135d288277a790769e87 Mon Sep 17 00:00:00 2001 From: mrtoine Date: Thu, 18 Dec 2025 14:41:11 +0100 Subject: [PATCH 37/38] =?UTF-8?q?Ajout=20du=20mod=C3=A8le=20`DiscordLevel`?= =?UTF-8?q?=20avec=20champs=20pour=20la=20gestion=20des=20niveaux=20et=20d?= =?UTF-8?q?e=20l'XP=20dans=20`discord=5Fintegration`.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../migrations/0002_discordlevel.py | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 discord_integration/migrations/0002_discordlevel.py diff --git a/discord_integration/migrations/0002_discordlevel.py b/discord_integration/migrations/0002_discordlevel.py new file mode 100644 index 0000000..b35f4b8 --- /dev/null +++ b/discord_integration/migrations/0002_discordlevel.py @@ -0,0 +1,24 @@ +# Generated by Django 6.0 on 2025-12-18 11:57 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('discord_integration', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='DiscordLevel', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('discord_id', models.BigIntegerField()), + ('total_xp', models.PositiveIntegerField(default=0)), + ('level', models.PositiveIntegerField(default=1)), + ('rank', models.TextField(default='Nouveau membre')), + ('last_message', models.DateTimeField(auto_now_add=True)), + ], + ), + ] From 40a8c6d10641f5fae3305a556e675a12b6ddbcbe Mon Sep 17 00:00:00 2001 From: mrtoine Date: Thu, 18 Dec 2025 14:50:36 +0100 Subject: [PATCH 38/38] =?UTF-8?q?Suppression=20des=20logs=20de=20d=C3=A9bo?= =?UTF-8?q?gage=20des=20signaux=20et=20am=C3=A9lioration=20du=20formatage?= =?UTF-8?q?=20du=20rang=20dans=20la=20r=C3=A9ponse=20Discord.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- discord_integration/core/level_logic.py | 2 +- discord_integration/signals.py | 10 +--------- 2 files changed, 2 insertions(+), 10 deletions(-) diff --git a/discord_integration/core/level_logic.py b/discord_integration/core/level_logic.py index a3051c6..fe42f8f 100644 --- a/discord_integration/core/level_logic.py +++ b/discord_integration/core/level_logic.py @@ -67,4 +67,4 @@ async def check_add_xp(message, client): async def get_xp(interaction: discord.Interaction): user_id = interaction.user.id user_db, _ = await sync_to_async(get_user)(user_id) - await interaction.response.send_message(f"{interaction.user.mention} : Tu as {user_db.total_xp} XP et tu es niveau {user_db.level} !\nTon rang est {user_db.rank} !") \ No newline at end of file + await interaction.response.send_message(f"{interaction.user.mention} : Tu as {user_db.total_xp} XP et tu es niveau {user_db.level} !\nTon rang est **{user_db.rank}** !") \ No newline at end of file diff --git a/discord_integration/signals.py b/discord_integration/signals.py index c69abe4..cd5897e 100644 --- a/discord_integration/signals.py +++ b/discord_integration/signals.py @@ -1,26 +1,18 @@ from django.db.models.signals import post_save from django.dispatch import receiver from .models import DiscordNotification -from blog.models import Post -from courses.models import Lesson, Course @receiver(post_save, sender="blog.Post") def create_discord_notification_blog(sender, instance, created, **kwargs): - print(f"DEBUG : Signal capté ! Nouveau objet : {instance}") if created: DiscordNotification.objects.create(content_object=instance) - print("DEBUG : Notification enregistée en base de données") @receiver(post_save, sender="courses.Course") def create_discord_notification_course(sender, instance, created, **kwargs): - print(f"DEBUG : Signal capté ! Nouveau objet : {instance}") if created: DiscordNotification.objects.create(content_object=instance) - print("DEBUG : Notification enregistée en base de données") @receiver(post_save, sender="courses.Lesson") def create_discord_notification_lesson(sender, instance, created, **kwargs): - print(f"DEBUG : Signal capté ! Nouveau objet : {instance}") if created: - DiscordNotification.objects.create(content_object=instance) - print("DEBUG : Notification enregistée en base de données") \ No newline at end of file + DiscordNotification.objects.create(content_object=instance) \ No newline at end of file