The admin and forms
Turn the Django admin into a usable back office, and build server-validated forms with ModelForm — including the CSRF token people always forget.
Configuring the admin
# blog/admin.py
from django.contrib import admin
from .models import Category, Post
@admin.register(Post)
class PostAdmin(admin.ModelAdmin):
list_display = ("title", "status", "category", "author", "created_at")
list_filter = ("status", "category", "created_at")
search_fields = ("title", "body", "author__username")
prepopulated_fields = {"slug": ("title",)}
raw_id_fields = ("author", "category")
readonly_fields = ("created_at",)
date_hierarchy = "created_at"
list_select_related = ("author", "category") # avoids N+1 in the list
actions = ["mark_published"]
@admin.action(description="Publish selected posts")
def mark_published(self, request, queryset):
for post in queryset:
post.publish()
self.message_user(request, f"{queryset.count()} posts published")
admin.site.register(Category)
admin.site.site_header = "Blog back office"python manage.py createsuperuser
# then sign in at /admin/ — only users with is_staff=True may enter| Option | Effect |
|---|---|
list_display | Columns in the change list |
list_filter | Sidebar filters |
search_fields | Search box; __ traverses relations |
list_select_related | Join related rows for the list view |
readonly_fields | Show but never write |
actions | Bulk operations on selected rows |
Forms and validation
# blog/forms.py
from django import forms
from django.core.exceptions import ValidationError
from .models import Comment, Post
class CommentForm(forms.ModelForm):
class Meta:
model = Comment
fields = ["text"]
widgets = {"text": forms.Textarea(attrs={"rows": 3, "maxlength": 1000})}
def clean_text(self):
text = self.cleaned_data["text"].strip()
if len(text) < 3:
raise ValidationError("Say a little more than that.")
if "http://" in text or "https://" in text:
raise ValidationError("Links are not allowed in comments.")
return text
class PostForm(forms.ModelForm):
class Meta:
model = Post
fields = ["title", "slug", "body", "category"]
def clean(self):
data = super().clean()
if data.get("body") and len(data["body"]) < 50:
self.add_error("body", "A published post should be at least 50 characters.")
return data
def save(self, commit=True):
post = super().save(commit=False)
post.author = self.author
if commit:
post.save()
return post# blog/views.py
from django.shortcuts import get_object_or_404, redirect, render
from .forms import CommentForm
from .models import Comment, Post
def add_comment(request, pk):
post = get_object_or_404(Post, pk=pk)
form = CommentForm(request.POST or None)
if request.method == "POST" and form.is_valid():
comment = form.save(commit=False)
comment.post = post
comment.author = request.user
comment.save()
return redirect(post.get_absolute_url()) # redirect after POST
return render(request, "blog/comment_form.html", {"form": form})⚠️
Every POST form needs
{% csrf_token %} inside the form tag, otherwise Django rejects the request with 403 Forbidden and a CSRF verification message. The same applies to AJAX sends — include the token from the cookie in the X-CSRFToken header.Rendering the form
<form method="post" action="{% url 'blog:comment' post.pk %}">
{% csrf_token %}
{{ form.non_field_errors }}
<div class="field">
{{ form.text.label_tag }}
{{ form.text }}
{{ form.text.errors }}
</div>
<button type="submit">Post comment</button>
</form>- Render fields individually to control layout;
{{ form.as_p }}is fine only for internal tools. form.errorsandform.non_field_errorsmust be rendered or the user sees a silent failure to submit.- Redirect after a successful POST so a refresh does not resubmit the form.
FAQ
Is the admin safe to expose?
It is a staff-only tool protected by authentication and permissions, but do not treat it as a public front end. Keep it on a private path or behind an IP allowlist, and give each operator the least permission they need.
ModelForm or plain Form?
Prefer
ModelForm when the input maps to a model: it generates fields from the model, reuses validators and saves with one call. Use a plain Form for inputs that do not correspond to stored rows, such as a search or login form.Related
Views and URLs Forms, sessions and errors
Last refreshed 2026-09-18.