Add comment system with models, forms, and UI integration for lessons
This commit is contained in:
parent
c22622ebc1
commit
95111240bc
26 changed files with 1001 additions and 77 deletions
|
|
@ -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
13
courses/forms.py
Normal 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())
|
||||
31
courses/migrations/0002_comment.py
Normal file
31
courses/migrations/0002_comment.py
Normal 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',),
|
||||
},
|
||||
),
|
||||
]
|
||||
19
courses/migrations/0003_comment_parent.py
Normal file
19
courses/migrations/0003_comment_parent.py
Normal 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'),
|
||||
),
|
||||
]
|
||||
|
|
@ -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}"
|
||||
0
courses/templatetags/__init__.py
Normal file
0
courses/templatetags/__init__.py
Normal file
108
courses/templatetags/comment_format.py
Normal file
108
courses/templatetags/comment_format.py
Normal 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)
|
||||
|
|
@ -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"),
|
||||
]
|
||||
100
courses/views.py
100
courses/views.py
|
|
@ -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)
|
||||
Loading…
Add table
Add a link
Reference in a new issue