Ajout des fonctionnalités de gestion de progression des cours : vue dédiée pour le toggle des leçons, mise à jour des templates pour afficher la progression, intégration des routes Ajax, styles associés, et ajustements des vues et modèles pour gérer les données utilisateur.

This commit is contained in:
mrtoine 2025-12-15 22:34:02 +01:00
parent 609745a723
commit ac8ef6894d
8 changed files with 261 additions and 9 deletions

View file

@ -3,6 +3,7 @@ from django.urls import reverse
from django.views import generic from django.views import generic
from django.db.models import Prefetch from django.db.models import Prefetch
from .models import Course, Lesson, Module, Comment from .models import Course, Lesson, Module, Comment
from progression.models import Progression
from .forms import CommentForm from .forms import CommentForm
def list_courses(request): def list_courses(request):
@ -23,9 +24,23 @@ def show(request, course_name, course_id):
.order_by('order') .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 = { context = {
'course': course, 'course': course,
'lessons': lessons, 'lessons': lessons,
'user_progress': user_progress,
'completed_lesson_ids': completed_lesson_ids,
} }
return render(request, 'courses/show.html', context) 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') .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 = { context = {
'course': course, 'course': course,
'module': module, 'module': module,
@ -114,5 +141,7 @@ def lesson_detail(request, course_slug, module_slug, lesson_slug):
'comment_form': form, 'comment_form': form,
'prev_lesson': prev_lesson, 'prev_lesson': prev_lesson,
'next_lesson': next_lesson, 'next_lesson': next_lesson,
'user_progress': user_progress,
'completed_lesson_ids': completed_lesson_ids,
} }
return render(request, 'courses/lesson.html', context) return render(request, 'courses/lesson.html', context)

View file

@ -43,7 +43,7 @@ urlpatterns = [
path('', include('home.urls')), path('', include('home.urls')),
path('courses/', include('courses.urls')), path('courses/', include('courses.urls')),
path('users/', include('users.urls')), path('users/', include('users.urls')),
path('progression/', include('progression.urls')),
path('blog/', include('blog.urls')), path('blog/', include('blog.urls')),
path('sitemap.xml', sitemap, {'sitemaps': sitemaps_dict}, name='django.contrib.sitemaps.views.sitemap'), path('sitemap.xml', sitemap, {'sitemaps': sitemaps_dict}, name='django.contrib.sitemaps.views.sitemap'),

View file

@ -2,6 +2,9 @@ from django.urls import path
from . import views from . import views
app_name = 'progression' 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'),
] ]

View file

@ -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
})

View file

@ -247,9 +247,9 @@ html { box-sizing: border-box; }
gap: 6px; gap: 6px;
padding: 2px 8px; padding: 2px 8px;
margin-left: 8px; margin-left: 8px;
border-radius: 999px; border-radius: 2px;
background: var(--accent); background: var(--accent);
color: var(--warning-contrast); color: var(--text);
font-size: 11px; font-size: 11px;
font-weight: 800; font-weight: 800;
letter-spacing: .3px; letter-spacing: .3px;
@ -259,6 +259,40 @@ html { box-sizing: border-box; }
white-space: nowrap; 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 { .courseToc .tocLink.disabled {
color: var(--text-muted); color: var(--text-muted);
background: transparent; background: transparent;
@ -269,6 +303,70 @@ html { box-sizing: border-box; }
font-weight: 500; 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'] { [data-theme='light'] {
/* Palette: plus nuancé, moins "blanc" */ /* Palette: plus nuancé, moins "blanc" */
--bg: #eef3f7; /* fond légèrement teinté bleu-gris */ --bg: #eef3f7; /* fond légèrement teinté bleu-gris */

View file

@ -4,3 +4,22 @@
Un cours proposé par&nbsp;<a href="{% url 'another_profile' course.author.id %}">{{ course.author }}</a> Un cours proposé par&nbsp;<a href="{% url 'another_profile' course.author.id %}">{{ course.author }}</a>
</p> </p>
<p>{{ course.content }}</p> <p>{{ course.content }}</p>
<strong>Progression pour ce cours</strong>
{% if user_progress.percent_completed == 100 %}
<div class="course-completed">
<div class="container">
<div class="icon"><i class="fa-solid fa-check"></i></div>
</div>
<div class="container">
<div class="title">Félicitation</div>
<div class="content">Tu as terminé(e) ce cours ! Tu peux réellement être fier(e) de toi !!</div>
</div>
</div>
{% else %}
<div class="progress-container">
<div class="progress-bar" style="width: 100%;">
<div id="progress-bar-fill" class="progress-bar-fill" style="width: {{ user_progress.percent_completed }}%; height: 100%; transition: width 0.3s;"></div>
</div>
<div id="progress-text" class="progress-text">{{ user_progress.percent_completed }}%</div>
</div>
{% endif %}

View file

@ -13,7 +13,8 @@
<li class="tocLesson{% if lesson and lesson.id == item.id %} current{% endif %}"> <li class="tocLesson{% if lesson and lesson.id == item.id %} current{% endif %}">
<a class="tocLink {% if item.is_premium and user.profile.is_premium == False %}premium{% endif %}" href="{% url 'courses:lesson_detail' course.slug item.module.slug item.slug %}"> <a class="tocLink {% if item.is_premium and user.profile.is_premium == False %}premium{% endif %}" href="{% url 'courses:lesson_detail' course.slug item.module.slug item.slug %}">
{{ item.name }} {% if item.is_premium %}<span class="premium-tag">PREMIUM</span>{% endif %} {{ item.name }} {% if item.is_premium %}<span class="premium-tag">PREMIUM</span>{% endif %}
{% if lesson and lesson.id == item.id %}<span class="tocCurrentTag">(cours actuel)</span>{% endif %} {% if lesson and lesson.id == item.id %}<span class="current-tag">(cours actuel)</span>{% endif %}
{% if item.id in completed_lesson_ids %}<span class="completed-tag">Terminé</span>{% endif %}
</a> </a>
{% if lesson and lesson.id == item.id %} {% if lesson and lesson.id == item.id %}
<div class="lessonInline"> <div class="lessonInline">
@ -69,6 +70,12 @@
<div class="content-lesson"> <div class="content-lesson">
<div class="lessonTitle">Ce que l'on voit durant ce cours : </div> <div class="lessonTitle">Ce que l'on voit durant ce cours : </div>
{{ lesson.content|comment_markdown }} {{ lesson.content|comment_markdown }}
{% if lesson.id in completed_lesson_ids %}
<button id="btn-complete" data-lesson-id="{{ lesson.id }}" class="btn btn-success">✅ Terminée</button>
{% else %}
<button id="btn-complete" data-lesson-id="{{ lesson.id }}" class="btn btn-secondary">Marquer comme terminé</button>
{% endif %}
</div> </div>
<h3 id="comments">Commentaires</h3> <h3 id="comments">Commentaires</h3>
<div class="lessonComments"> <div class="lessonComments">
@ -251,6 +258,64 @@
}); });
})(); })();
</script> </script>
<!-- Progression Bar -->
<script>
document.addEventListener('DOMContentLoaded', function() {
const btn = document.getElementById('btn-complete');
const progressBar = document.getElementById('progress-bar-fill');
const progressText = document.getElementById('progress-text');
btn.addEventListener('click', function() {
const lessonId = this.getAttribute('data-lesson-id');
// 1. On prépare la requête
fetch("{% url 'progression:toggle_lesson' %}", { // Assure-toi que c'est le bon nom dans urls.py
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': getCookie('csrftoken') // Fonction indispensable pour Django
},
body: JSON.stringify({
'lesson_id': lessonId
})
})
.then(response => response.json())
.then(data => {
if (data.status === 'success') {
// 2. On met à jour le visuel de la barre
progressBar.style.width = data.new_percent + '%';
progressText.innerText = data.new_percent + '%';
// 3. On change l'aspect du bouton
if (data.is_completed) {
btn.innerText = "✅ Terminée";
btn.style.backgroundColor = "green";
} else {
btn.innerText = "Marquer comme terminé";
btn.style.backgroundColor = "gray";
}
}
});
});
// --- Fonction Helper pour récupérer le cookie CSRF de Django ---
function getCookie(name) {
let cookieValue = null;
if (document.cookie && document.cookie !== '') {
const cookies = document.cookie.split(';');
for (let i = 0; i < cookies.length; i++) {
const cookie = cookies[i].trim();
if (cookie.substring(0, name.length + 1) === (name + '=')) {
cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
break;
}
}
}
return cookieValue;
}
});
</script>
</div> <!-- /.lessonComments --> </div> <!-- /.lessonComments -->
</div> <!-- /.lesson --> </div> <!-- /.lesson -->
</div> <!-- /.lessonInline --> </div> <!-- /.lessonInline -->

View file

@ -39,8 +39,8 @@
(function() { (function() {
try { try {
var stored = localStorage.getItem('pdz-theme'); var stored = localStorage.getItem('pdz-theme');
var prefersLight = window.matchMedia && window.matchMedia('(prefers-color-scheme: light)').matches; var prefersLight = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
var theme = stored || (prefersLight ? 'light' : 'dark'); var theme = stored || (prefersLight ? 'dark' : 'light');
document.documentElement.setAttribute('data-theme', theme); document.documentElement.setAttribute('data-theme', theme);
if (theme === 'light') { if (theme === 'light') {
var link = document.getElementById('theme-css'); var link = document.getElementById('theme-css');