The recording for this lesson has not been published yet.
Until now every row in your database was written by you through the admin. A comment form changes that: a stranger types text and your site shows it to everybody else. Without moderation you are running a free advertising service for spammers within a week.
The plan is small and standard:
Comment model with a boolean is_approved, default
False.CommentForm on the post detail page.# blog/models.py
from django.conf import settings
from django.db import models
from django.utils import timezone
class ApprovedCommentManager(models.Manager):
def get_queryset(self):
return super().get_queryset().filter(is_approved=True)
class Comment(models.Model):
post = models.ForeignKey(
"blog.Post", on_delete=models.CASCADE, related_name="comments"
)
author = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="comments",
)
author_name = models.CharField("name", max_length=80)
email = models.EmailField(blank=True)
body = models.TextField("comment", max_length=2000)
created_at = models.DateTimeField(default=timezone.now)
is_approved = models.BooleanField(default=False, db_index=True)
objects = models.Manager() # the default: everything
approved = ApprovedCommentManager() # only what may be shown
class Meta:
ordering = ["created_at"]
indexes = [models.Index(fields=["post", "is_approved"])]
def __str__(self):
return f"{self.author_name} on {self.post}"
def approve(self):
self.is_approved = True
self.save(update_fields=["is_approved"])
python manage.py makemigrations blog
python manage.py migrate
The manager declared first becomes _default_manager, which the admin and
related lookups use. Declare objects first and approved
second, or the admin will hide the very comments you need to moderate.
fields lists only what a visitor may fill in. post,
author and is_approved are set by the view - never by the browser.
# blog/forms.py
from django import forms
from django.core.exceptions import ValidationError
from .models import Comment
BANNED = ("http://", "https://", "casino", "crypto")
class CommentForm(forms.ModelForm):
class Meta:
model = Comment
fields = ["author_name", "email", "body"]
widgets = {
"author_name": forms.TextInput(attrs={"class": "form-control"}),
"email": forms.EmailInput(attrs={"class": "form-control"}),
"body": forms.Textarea(attrs={"class": "form-control", "rows": 4}),
}
labels = {"email": "Email (never published)"}
def clean_body(self):
body = self.cleaned_data["body"].strip()
if len(body) < 5:
raise ValidationError("That is a bit short for a comment.")
lowered = body.lower()
if sum(word in lowered for word in BANNED) >= 2:
raise ValidationError("This looks like spam, so it was not accepted.")
return body
One view handles both the GET that shows the page and the POST that adds a comment. On success you redirect - see lesson 5.5 for why.
# blog/views.py
from django.contrib import messages
from django.shortcuts import get_object_or_404, redirect, render
from .forms import CommentForm
from .models import Post
def post_detail(request, pk):
post = get_object_or_404(Post, pk=pk)
form = CommentForm(request.POST or None)
if request.method == "POST":
if form.is_valid():
comment = form.save(commit=False)
comment.post = post
if request.user.is_authenticated:
comment.author = request.user
comment.author_name = request.user.get_username()
comment.is_approved = True # trust your own users
comment.save()
messages.success(
request, "Thanks! Your comment will appear once it is approved."
)
return redirect(f"{post.get_absolute_url()}#comments")
messages.error(request, "Please fix the errors below.")
return render(
request,
"blog/post_detail.html",
{"post": post, "comment_form": form},
)
<h2 id="comments">Comments ({{ post.approved_comments.count }})</h2>
{% for comment in post.approved_comments %}
<div class="card mb-2">
<div class="card-body">
<div class="text-secondary">
{{ comment.author_name }} - {{ comment.created_at|date:"j F Y H:i" }}
</div>
<p class="mb-0">{{ comment.body|linebreaksbr }}</p>
</div>
</div>
{% empty %}
<p class="text-secondary">No comments yet. Be the first.</p>
{% endfor %}
<form method="post" novalidate>
{% csrf_token %}
{{ comment_form.as_div }}
<button class="btn btn-primary">Post comment</button>
</form>
A related manager such as post.comments is built from the model's default
manager, so post.comments.approved does not exist - and a template cannot call
filter() either, because it cannot pass arguments. Put the rule on the model
instead:
class Post(models.Model):
...
@property
def approved_comments(self):
return self.comments.filter(is_approved=True)
The template just iterates {{ post.approved_comments }},
and Comment.approved covers the places where you start from the comments
themselves, for example a "latest comments" sidebar. The rule lives in Python, not in the
template - so no template can leak a pending comment.
To avoid one query per post in the list page, prefetch the filtered set:
from django.db.models import Count, Prefetch, Q
posts = (
Post.objects.annotate(
comment_count=Count("comments", filter=Q(comments__is_approved=True))
)
.prefetch_related(
Prefetch(
"comments",
queryset=Comment.objects.filter(is_approved=True),
to_attr="visible_comments",
)
)
)
# blog/admin.py
from django.contrib import admin, messages
from .models import Comment
@admin.action(description="Approve selected comments")
def approve_comments(modeladmin, request, queryset):
updated = queryset.update(is_approved=True)
modeladmin.message_user(
request, f"{updated} comment(s) approved.", messages.SUCCESS
)
@admin.action(description="Reject (un-approve) selected comments")
def reject_comments(modeladmin, request, queryset):
updated = queryset.update(is_approved=False)
modeladmin.message_user(request, f"{updated} comment(s) rejected.", messages.WARNING)
@admin.register(Comment)
class CommentAdmin(admin.ModelAdmin):
list_display = ["author_name", "post", "created_at", "is_approved"]
list_filter = ["is_approved", "created_at"]
search_fields = ["author_name", "email", "body"]
list_editable = ["is_approved"]
list_select_related = ["post"]
date_hierarchy = "created_at"
actions = [approve_comments, reject_comments]
def get_queryset(self, request):
return super().get_queryset(request).select_related("post", "author")
It is one fast SQL statement, which is exactly what you want for bulk approval. If you
need per-object logic (sending a notification), loop and call
comment.approve() instead.
Reviewing comments post by post is much faster with an inline on the post page:
class CommentInline(admin.TabularInline):
model = Comment
extra = 0
fields = ["author_name", "body", "is_approved", "created_at"]
readonly_fields = ["created_at"]
show_change_link = True
@admin.register(Post)
class PostAdmin(admin.ModelAdmin):
inlines = [CommentInline]
Django escapes output by default, and that escaping is the only thing standing between
your readers and a stored cross-site-scripting attack. Use
|linebreaksbr for line breaks; if you really need formatting, run the text
through a sanitiser such as nh3 or bleach first.
Comment model with is_approved, make the migration
and check the SQL with sqlmigrate.CommentForm and the POST branch of post_detail. Submit a
comment and confirm it does not appear on the page yet.post.comments.all, see the pending
comment leak, then switch to approved_comments and watch it disappear.Comment.objects.get().is_approved is False.clean_body a spam rule of your own and cover it with a test.