Add comment system with models, forms, and UI integration for lessons

This commit is contained in:
mrtoine 2025-12-10 22:22:17 +01:00
parent c22622ebc1
commit 95111240bc
26 changed files with 1001 additions and 77 deletions

View file

@ -1,16 +1,56 @@
from django.contrib import admin
from .models import *
from .models import Course, Module, Lesson, Comment
# Register your models here.
class CategoriesAdmin(admin.ModelAdmin):
fieldsets = [
('Nom', {'fields': ['name']}),
('Description', {'fields': ['content']}),
('Date de cr"ation', {'fields': ['created_at']}),
]
list_display = ('name', 'content', 'type')
list_filter = ['type', 'name']
search_fields = ['name', 'content']
admin.site.register(Course)
admin.site.register(Lesson)
class LessonInline(admin.TabularInline):
model = Lesson
extra = 0
fields = ("name", "slug", "is_premium", "order")
ordering = ("order",)
prepopulated_fields = {"slug": ("name",)}
class ModuleInline(admin.TabularInline):
model = Module
extra = 0
fields = ("name", "slug", "enable", "order")
ordering = ("order",)
prepopulated_fields = {"slug": ("name",)}
@admin.register(Course)
class CourseAdmin(admin.ModelAdmin):
list_display = ("name", "slug", "author", "enable", "created_at", "updated_at")
list_filter = ("enable", "author", "created_at", "updated_at")
search_fields = ("name", "tags", "description", "author__username")
prepopulated_fields = {"slug": ("name",)}
readonly_fields = ("created_at", "updated_at")
inlines = [ModuleInline]
@admin.register(Module)
class ModuleAdmin(admin.ModelAdmin):
list_display = ("name", "slug", "course", "enable", "order", "created_at", "updated_at")
list_filter = ("enable", "course", "created_at", "updated_at")
search_fields = ("name", "description", "course__name")
prepopulated_fields = {"slug": ("name",)}
readonly_fields = ("created_at", "updated_at")
ordering = ("course", "order")
inlines = [LessonInline]
@admin.register(Lesson)
class LessonAdmin(admin.ModelAdmin):
list_display = ("name", "slug", "module", "is_premium", "order")
list_filter = ("is_premium", "module")
search_fields = ("name", "content", "module__name")
prepopulated_fields = {"slug": ("name",)}
ordering = ("module", "order")
@admin.register(Comment)
class CommentAdmin(admin.ModelAdmin):
list_display = ("lesson", "user", "created_at", "is_active")
list_filter = ("is_active", "created_at")
search_fields = ("content", "user__username", "lesson__name")
readonly_fields = ("created_at", "updated_at")

13
courses/forms.py Normal file
View file

@ -0,0 +1,13 @@
from django import forms
class CommentForm(forms.Form):
content = forms.CharField(
label="",
widget=forms.Textarea(attrs={
"rows": 3,
"placeholder": "Écrire un commentaire…",
}),
max_length=5000,
)
parent = forms.IntegerField(required=False, widget=forms.HiddenInput())

View file

@ -0,0 +1,31 @@
# Generated by Django 6.0 on 2025-12-10 20:35
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('courses', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Comment',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('content', models.TextField()),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('is_active', models.BooleanField(default=True)),
('lesson', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='comments', to='courses.lesson')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='lesson_comments', to=settings.AUTH_USER_MODEL)),
],
options={
'ordering': ('created_at',),
},
),
]

View file

@ -0,0 +1,19 @@
# Generated by Django 6.0 on 2025-12-10 22:15
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('courses', '0002_comment'),
]
operations = [
migrations.AddField(
model_name='comment',
name='parent',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='replies', to='courses.comment'),
),
]

View file

@ -41,4 +41,20 @@ class Lesson(models.Model):
self.content = self.content.replace('?>', '?>')
def __str__(self):
return self.name
return self.name
class Comment(models.Model):
lesson = models.ForeignKey(Lesson, on_delete=models.CASCADE, related_name='comments')
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='lesson_comments')
content = models.TextField()
parent = models.ForeignKey('self', on_delete=models.CASCADE, null=True, blank=True, related_name='replies')
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
is_active = models.BooleanField(default=True)
class Meta:
ordering = ("created_at",)
def __str__(self):
return f"Comment by {self.user} on {self.lesson}"

View file

View file

@ -0,0 +1,108 @@
from django import template
from django.utils.html import conditional_escape, escape
from django.utils.safestring import mark_safe
import re
register = template.Library()
def _link_repl(match):
text = match.group(1)
url = match.group(2)
# Allow only http/https links
if not re.match(r"^(https?://)", url, re.IGNORECASE):
# The whole input has already been HTML-escaped upstream.
return text
return (
f'<a class="comment-link" href="{url}" '
f'rel="nofollow noopener noreferrer" target="_blank">{text}</a>'
)
def _format_inline(text: str) -> str:
# code spans first
text = re.sub(
r"`([^`]+)`",
# Content is already HTML-escaped by conditional_escape; avoid double-escaping
lambda m: f"<code class=\"comment-code\">{m.group(1)}</code>",
text,
)
# bold **text**
text = re.sub(
r"\*\*([^*]+)\*\*",
lambda m: f"<strong class=\"comment-strong\">{m.group(1)}</strong>",
text,
)
# italic *text* or _text_
text = re.sub(
r"(?<!\*)\*([^*]+)\*(?!\*)",
lambda m: f"<em class=\"comment-em\">{m.group(1)}</em>",
text,
)
text = re.sub(
r"_([^_]+)_",
lambda m: f"<em class=\"comment-em\">{m.group(1)}</em>",
text,
)
# links [text](url)
text = re.sub(r"\[([^\]]+)\]\(([^)]+)\)", _link_repl, text)
return text
def _format_lists(lines):
html_lines = []
in_ul = False
for line in lines:
m = re.match(r"^\s*[-*]\s+(.+)$", line)
if m:
if not in_ul:
html_lines.append("<ul class=\"comment-list\">")
in_ul = True
html_lines.append(f"<li>{_format_inline(m.group(1))}</li>")
else:
if in_ul:
html_lines.append("</ul>")
in_ul = False
html_lines.append(_format_inline(line))
if in_ul:
html_lines.append("</ul>")
return html_lines
@register.filter(name="comment_markdown")
def comment_markdown(value: str):
"""A very small Markdown subset renderer for comments with HTML escaping.
Supports: paragraphs, lists (- or *), **bold**, *italic* or _italic_, `code`, [text](url).
Fenced code blocks ```...``` will render as <pre><code>.
All HTML is escaped.
"""
if not value:
return ""
text = str(value)
# Normalize newlines
text = text.replace("\r\n", "\n").replace("\r", "\n")
# Escape HTML entirely first
text = conditional_escape(text)
# Handle fenced code blocks ```
parts = re.split(r"```\n?", text)
rendered = []
for i, part in enumerate(parts):
if i % 2 == 1:
# inside code block
rendered.append(f"<pre class=\"comment-pre\"><code class=\"comment-code-block\">{part}</code></pre>")
else:
# regular markdown in this chunk
# Split into paragraphs by blank lines
paragraphs = re.split(r"\n\s*\n", part)
for p in paragraphs:
lines = p.split("\n")
formatted_lines = _format_lists(lines)
html = "<br />".join(l for l in formatted_lines if l != "")
if html.strip():
rendered.append(f"<p class=\"comment-paragraph\">{html}</p>")
html_out = "\n".join(rendered)
return mark_safe(html_out)

View file

@ -4,5 +4,7 @@ from . import views
app_name = 'courses'
urlpatterns = [
path('', views.list, name='list'),
path('<int:course_id>-<slug:course_name>/', views.show, name="show"),
# Lesson detail: /courses/<course>/<module>/<lesson>/
path('<slug:course_slug>/<slug:module_slug>/<slug:lesson_slug>/', views.lesson_detail, name='lesson_detail'),
path('<slug:course_name>-<int:course_id>/', views.show, name="show"),
]

View file

@ -1,12 +1,102 @@
from django.shortcuts import render, get_object_or_404
from django.shortcuts import render, get_object_or_404, redirect
from django.urls import reverse
from django.views import generic
from .models import Course, Lesson
from django.db.models import Prefetch
from .models import Course, Lesson, Module, Comment
from .forms import CommentForm
def list(request):
courses = Course.objects.all()
return render(request, 'courses/list.html', {'courses': courses})
def show(request, course_name, course_id):
course = get_object_or_404(Course, pk=course_id)
lessons = Lesson.objects.filter(course_id=course_id)
return render(request, 'courses/show.html', {'course' : course, 'lessons': lessons})
# Optimized course fetch with related author and profile (if present)
course = get_object_or_404(
Course.objects.select_related('author', 'author__profile'),
pk=course_id,
)
# Fetch lessons through module -> course relation and keep a stable order
lessons = (
Lesson.objects.filter(module__course_id=course_id)
.select_related('module')
.order_by('order')
)
context = {
'course': course,
'lessons': lessons,
}
return render(request, 'courses/show.html', context)
def lesson_detail(request, course_slug, module_slug, lesson_slug):
# Fetch course by slug
course = get_object_or_404(
Course.objects.select_related('author', 'author__profile'),
slug=course_slug,
)
# Fetch module within course
module = get_object_or_404(
Module.objects.filter(course=course),
slug=module_slug,
)
# Fetch lesson within module
lesson = get_object_or_404(
Lesson.objects.select_related('module').filter(module=module),
slug=lesson_slug,
)
# Handle comment submission
if request.method == 'POST':
if not request.user.is_authenticated:
login_url = reverse('login')
return redirect(f"{login_url}?next={request.path}")
form = CommentForm(request.POST)
if form.is_valid():
parent = None
parent_id = form.cleaned_data.get('parent')
if parent_id:
try:
parent = Comment.objects.get(id=parent_id, lesson=lesson, is_active=True)
except Comment.DoesNotExist:
parent = None
Comment.objects.create(
lesson=lesson,
user=request.user,
content=form.cleaned_data['content'],
parent=parent,
)
# Redirect to anchor to show the new comment
return redirect(request.path + "#comments")
else:
form = CommentForm()
# Lessons list for TOC (entire course ordered)
lessons = (
Lesson.objects.filter(module__course=course)
.select_related('module')
.order_by('order')
)
# Public comments for the current lesson (top-level) and their replies
replies_qs = Comment.objects.filter(is_active=True).select_related('user').order_by('created_at')
comments = (
Comment.objects.filter(lesson=lesson, is_active=True, parent__isnull=True)
.select_related('user')
.prefetch_related(Prefetch('replies', queryset=replies_qs))
.order_by('created_at')
)
context = {
'course': course,
'module': module,
'lesson': lesson,
'lessons': lessons,
'comments': comments,
'comment_form': form,
}
return render(request, 'courses/lesson.html', context)