Django's ModelForm class provides automatic form generation from model definitions, streamlining the process of creating forms that correspond to database models. This chapter covers ModelForm creation, customization, and advanced patterns for efficient data handling.
# models.py
from django.db import models
from django.contrib.auth.models import User
class Article(models.Model):
title = models.CharField(max_length=200)
slug = models.SlugField(unique=True)
author = models.ForeignKey(User, on_delete=models.CASCADE)
content = models.TextField()
excerpt = models.TextField(max_length=300, blank=True)
published = models.BooleanField(default=False)
featured = models.BooleanField(default=False)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ['-created_at']
def __str__(self):
return self.title
# forms.py
from django import forms
from .models import Article
class ArticleForm(forms.ModelForm):
"""Basic ModelForm for Article model"""
class Meta:
model = Article
fields = ['title', 'slug', 'content', 'excerpt', 'published', 'featured']
# Custom widgets
widgets = {
'title': forms.TextInput(attrs={
'class': 'form-control',
'placeholder': 'Enter article title'
}),
'slug': forms.TextInput(attrs={
'class': 'form-control',
'placeholder': 'url-friendly-slug'
}),
'content': forms.Textarea(attrs={
'class': 'form-control',
'rows': 15,
'placeholder': 'Write your article content here...'
}),
'excerpt': forms.Textarea(attrs={
'class': 'form-control',
'rows': 3,
'placeholder': 'Brief summary of the article'
}),
'published': forms.CheckboxInput(attrs={
'class': 'form-check-input'
}),
'featured': forms.CheckboxInput(attrs={
'class': 'form-check-input'
}),
}
# Custom labels
labels = {
'title': 'Article Title',
'slug': 'URL Slug',
'content': 'Article Content',
'excerpt': 'Article Summary',
'published': 'Publish Article',
'featured': 'Feature Article',
}
# Help text
help_texts = {
'slug': 'URL-friendly version of the title (letters, numbers, hyphens, underscores)',
'excerpt': 'Brief summary shown in article listings (max 300 characters)',
'published': 'Check to make article visible to readers',
'featured': 'Check to highlight article on homepage',
}
# views.py
from django.shortcuts import render, redirect, get_object_or_404
from django.contrib import messages
from .forms import ArticleForm
from .models import Article
def create_article(request):
if request.method == 'POST':
form = ArticleForm(request.POST)
if form.is_valid():
article = form.save(commit=False)
article.author = request.user
article.save()
messages.success(request, 'Article created successfully!')
return redirect('article_detail', pk=article.pk)
else:
form = ArticleForm()
return render(request, 'articles/create.html', {'form': form})
def edit_article(request, pk):
article = get_object_or_404(Article, pk=pk, author=request.user)
if request.method == 'POST':
form = ArticleForm(request.POST, instance=article)
if form.is_valid():
form.save()
messages.success(request, 'Article updated successfully!')
return redirect('article_detail', pk=article.pk)
else:
form = ArticleForm(instance=article)
return render(request, 'articles/edit.html', {'form': form, 'article': article})
# forms.py - Field control examples
from django import forms
from .models import Article, User
class ArticleCreateForm(forms.ModelForm):
"""Form for creating new articles"""
class Meta:
model = Article
fields = ['title', 'slug', 'content', 'excerpt'] # Specific fields only
def __init__(self, *args, **kwargs):
self.user = kwargs.pop('user', None)
super().__init__(*args, **kwargs)
# Auto-generate slug from title
self.fields['slug'].required = False
self.fields['slug'].help_text = 'Leave blank to auto-generate from title'
class ArticleEditForm(forms.ModelForm):
"""Form for editing existing articles"""
class Meta:
model = Article
exclude = ['author', 'created_at', 'updated_at'] # Exclude specific fields
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Make slug read-only for existing articles
if self.instance.pk:
self.fields['slug'].widget.attrs['readonly'] = True
self.fields['slug'].help_text = 'Slug cannot be changed after publication'
class ArticleAdminForm(forms.ModelForm):
"""Comprehensive form for admin users"""
class Meta:
model = Article
fields = '__all__' # Include all fields
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Customize author field for admins
self.fields['author'].queryset = User.objects.filter(is_active=True)
self.fields['author'].empty_label = 'Select Author'
class ArticlePublishForm(forms.ModelForm):
"""Simple form for publishing/unpublishing articles"""
class Meta:
model = Article
fields = ['published', 'featured']
widgets = {
'published': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
'featured': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
}
# forms.py - ModelForm with custom validation
from django import forms
from django.core.exceptions import ValidationError
from django.utils.text import slugify
from .models import Article
class ValidatedArticleForm(forms.ModelForm):
"""ModelForm with comprehensive validation"""
class Meta:
model = Article
fields = ['title', 'slug', 'content', 'excerpt', 'published', 'featured']
def clean_title(self):
"""Validate article title"""
title = self.cleaned_data['title']
# Check title length
if len(title) < 5:
raise ValidationError('Title must be at least 5 characters long.')
# Check for duplicate titles
queryset = Article.objects.filter(title__iexact=title)
if self.instance.pk:
queryset = queryset.exclude(pk=self.instance.pk)
if queryset.exists():
raise ValidationError('An article with this title already exists.')
# Check for inappropriate content
inappropriate_words = ['spam', 'fake', 'scam']
if any(word in title.lower() for word in inappropriate_words):
raise ValidationError('Title contains inappropriate content.')
return title
def clean_slug(self):
"""Validate and auto-generate slug"""
slug = self.cleaned_data.get('slug')
title = self.cleaned_data.get('title')
# Auto-generate slug if not provided
if not slug and title:
slug = slugify(title)
# Validate slug format
if slug:
import re
if not re.match(r'^[-\w]+$', slug):
raise ValidationError('Slug can only contain letters, numbers, hyphens, and underscores.')
# Check for duplicate slugs
queryset = Article.objects.filter(slug=slug)
if self.instance.pk:
queryset = queryset.exclude(pk=self.instance.pk)
if queryset.exists():
raise ValidationError('An article with this slug already exists.')
return slug
def clean_content(self):
"""Validate article content"""
content = self.cleaned_data['content']
# Minimum content length
if len(content.strip()) < 100:
raise ValidationError('Article content must be at least 100 characters long.')
# Check for placeholder text
placeholder_phrases = ['lorem ipsum', 'placeholder text', 'sample content']
content_lower = content.lower()
if any(phrase in content_lower for phrase in placeholder_phrases):
raise ValidationError('Please replace placeholder text with actual content.')
return content
def clean_excerpt(self):
"""Validate article excerpt"""
excerpt = self.cleaned_data.get('excerpt', '')
content = self.cleaned_data.get('content', '')
# Auto-generate excerpt if not provided
if not excerpt and content:
# Take first 200 characters and add ellipsis
excerpt = content[:200].strip()
if len(content) > 200:
excerpt += '...'
# Validate excerpt length
if len(excerpt) > 300:
raise ValidationError('Excerpt cannot exceed 300 characters.')
return excerpt
def clean(self):
"""Form-level validation"""
cleaned_data = super().clean()
published = cleaned_data.get('published')
content = cleaned_data.get('content')
excerpt = cleaned_data.get('excerpt')
# Require excerpt for published articles
if published and not excerpt:
raise ValidationError('Published articles must have an excerpt.')
# Ensure content is substantial for published articles
if published and content and len(content.strip()) < 500:
raise ValidationError('Published articles should have at least 500 characters of content.')
return cleaned_data
def save(self, commit=True):
"""Custom save method"""
article = super().save(commit=False)
# Auto-generate slug if not provided
if not article.slug:
article.slug = slugify(article.title)
# Set publication timestamp
if article.published and not hasattr(article, 'published_at'):
from django.utils import timezone
article.published_at = timezone.now()
if commit:
article.save()
return article
# models.py - Related models
from django.db import models
from django.contrib.auth.models import User
class Category(models.Model):
name = models.CharField(max_length=100)
slug = models.SlugField(unique=True)
description = models.TextField(blank=True)
def __str__(self):
return self.name
class Tag(models.Model):
name = models.CharField(max_length=50)
slug = models.SlugField(unique=True)
def __str__(self):
return self.name
class Article(models.Model):
title = models.CharField(max_length=200)
slug = models.SlugField(unique=True)
author = models.ForeignKey(User, on_delete=models.CASCADE)
category = models.ForeignKey(Category, on_delete=models.SET_NULL, null=True, blank=True)
tags = models.ManyToManyField(Tag, blank=True)
content = models.TextField()
published = models.BooleanField(default=False)
created_at = models.DateTimeField(auto_now_add=True)
# forms.py - Handling related fields
from django import forms
from .models import Article, Category, Tag, User
class ArticleWithRelationsForm(forms.ModelForm):
"""ModelForm handling related models"""
# Custom field for tags with better widget
tags = forms.ModelMultipleChoiceField(
queryset=Tag.objects.all(),
widget=forms.CheckboxSelectMultiple,
required=False,
help_text='Select all applicable tags'
)
class Meta:
model = Article
fields = ['title', 'slug', 'author', 'category', 'tags', 'content', 'published']
widgets = {
'author': forms.Select(attrs={'class': 'form-select'}),
'category': forms.Select(attrs={'class': 'form-select'}),
'content': forms.Textarea(attrs={'class': 'form-control', 'rows': 10}),
}
def __init__(self, *args, **kwargs):
# Extract user for filtering
user = kwargs.pop('user', None)
super().__init__(*args, **kwargs)
# Filter author choices based on permissions
if user and not user.is_staff:
self.fields['author'].queryset = User.objects.filter(pk=user.pk)
self.fields['author'].initial = user
self.fields['author'].widget = forms.HiddenInput()
else:
self.fields['author'].queryset = User.objects.filter(is_active=True)
# Filter categories to active ones
self.fields['category'].queryset = Category.objects.filter(active=True)
self.fields['category'].empty_label = 'Select Category'
# Order tags alphabetically
self.fields['tags'].queryset = Tag.objects.order_by('name')
class CategoryForm(forms.ModelForm):
"""Form for managing categories"""
class Meta:
model = Category
fields = ['name', 'slug', 'description']
widgets = {
'name': forms.TextInput(attrs={'class': 'form-control'}),
'slug': forms.TextInput(attrs={'class': 'form-control'}),
'description': forms.Textarea(attrs={'class': 'form-control', 'rows': 4}),
}
def clean_slug(self):
slug = self.cleaned_data['slug']
# Auto-generate slug from name if not provided
if not slug:
from django.utils.text import slugify
slug = slugify(self.cleaned_data.get('name', ''))
return slug
class TagForm(forms.ModelForm):
"""Form for managing tags"""
class Meta:
model = Tag
fields = ['name', 'slug']
widgets = {
'name': forms.TextInput(attrs={'class': 'form-control'}),
'slug': forms.TextInput(attrs={'class': 'form-control'}),
}
def clean_name(self):
name = self.cleaned_data['name']
# Normalize tag name
name = name.strip().title()
# Check for duplicates
queryset = Tag.objects.filter(name__iexact=name)
if self.instance.pk:
queryset = queryset.exclude(pk=self.instance.pk)
if queryset.exists():
raise forms.ValidationError('A tag with this name already exists.')
return name
# forms.py - Inline formsets for related models
from django import forms
from django.forms import inlineformset_factory
from .models import Article, Comment, Image
class CommentForm(forms.ModelForm):
"""Form for article comments"""
class Meta:
model = Comment
fields = ['author_name', 'author_email', 'content']
widgets = {
'author_name': forms.TextInput(attrs={'class': 'form-control'}),
'author_email': forms.EmailInput(attrs={'class': 'form-control'}),
'content': forms.Textarea(attrs={'class': 'form-control', 'rows': 4}),
}
# Create inline formset for comments
CommentFormSet = inlineformset_factory(
Article,
Comment,
form=CommentForm,
extra=1, # Number of empty forms to display
can_delete=True, # Allow deletion of existing comments
can_order=True, # Allow reordering
)
class ImageForm(forms.ModelForm):
"""Form for article images"""
class Meta:
model = Image
fields = ['image', 'caption', 'alt_text']
widgets = {
'image': forms.FileInput(attrs={'class': 'form-control', 'accept': 'image/*'}),
'caption': forms.TextInput(attrs={'class': 'form-control'}),
'alt_text': forms.TextInput(attrs={'class': 'form-control'}),
}
# Create inline formset for images
ImageFormSet = inlineformset_factory(
Article,
Image,
form=ImageForm,
extra=2,
can_delete=True,
max_num=5, # Maximum number of images
)
# views.py - Using inline formsets
from django.shortcuts import render, redirect, get_object_or_404
from django.contrib import messages
from .forms import ArticleForm, CommentFormSet, ImageFormSet
def edit_article_with_relations(request, pk):
article = get_object_or_404(Article, pk=pk)
if request.method == 'POST':
form = ArticleForm(request.POST, instance=article)
comment_formset = CommentFormSet(request.POST, instance=article)
image_formset = ImageFormSet(request.POST, request.FILES, instance=article)
if form.is_valid() and comment_formset.is_valid() and image_formset.is_valid():
# Save article
article = form.save()
# Save comments
comments = comment_formset.save(commit=False)
for comment in comments:
comment.article = article
comment.save()
comment_formset.save_m2m()
# Save images
images = image_formset.save(commit=False)
for image in images:
image.article = article
image.save()
image_formset.save_m2m()
# Handle deletions
for obj in comment_formset.deleted_objects:
obj.delete()
for obj in image_formset.deleted_objects:
obj.delete()
messages.success(request, 'Article and related content updated successfully!')
return redirect('article_detail', pk=article.pk)
else:
form = ArticleForm(instance=article)
comment_formset = CommentFormSet(instance=article)
image_formset = ImageFormSet(instance=article)
return render(request, 'articles/edit_with_relations.html', {
'form': form,
'article': article,
'comment_formset': comment_formset,
'image_formset': image_formset,
})
# forms.py - Dynamic ModelForm generation
from django import forms
from django.forms import modelform_factory
def create_article_form(user, fields=None):
"""Create ArticleForm based on user permissions"""
# Default fields for regular users
default_fields = ['title', 'slug', 'content', 'excerpt']
# Additional fields for staff users
if user.is_staff:
default_fields.extend(['published', 'featured', 'author'])
# Use provided fields or defaults
form_fields = fields or default_fields
# Create form class dynamically
ArticleForm = modelform_factory(
Article,
fields=form_fields,
widgets={
'title': forms.TextInput(attrs={'class': 'form-control'}),
'slug': forms.TextInput(attrs={'class': 'form-control'}),
'content': forms.Textarea(attrs={'class': 'form-control', 'rows': 15}),
'excerpt': forms.Textarea(attrs={'class': 'form-control', 'rows': 3}),
}
)
# Customize form based on user
class CustomizedArticleForm(ArticleForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Set author for non-staff users
if not user.is_staff and 'author' in self.fields:
self.fields['author'].initial = user
self.fields['author'].widget = forms.HiddenInput()
return CustomizedArticleForm
# views.py - Using dynamic forms
def create_article_dynamic(request):
# Create form based on user permissions
ArticleForm = create_article_form(request.user)
if request.method == 'POST':
form = ArticleForm(request.POST)
if form.is_valid():
article = form.save(commit=False)
if not hasattr(article, 'author') or not article.author:
article.author = request.user
article.save()
return redirect('article_detail', pk=article.pk)
else:
form = ArticleForm()
return render(request, 'articles/create.html', {'form': form})
# models.py - Model with file fields
from django.db import models
from django.contrib.auth.models import User
class Document(models.Model):
title = models.CharField(max_length=200)
description = models.TextField(blank=True)
file = models.FileField(upload_to='documents/%Y/%m/')
thumbnail = models.ImageField(upload_to='thumbnails/%Y/%m/', blank=True)
author = models.ForeignKey(User, on_delete=models.CASCADE)
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
return self.title
# forms.py - File upload form
from django import forms
from django.core.exceptions import ValidationError
from .models import Document
class DocumentForm(forms.ModelForm):
"""Form for document upload with validation"""
class Meta:
model = Document
fields = ['title', 'description', 'file', 'thumbnail']
widgets = {
'title': forms.TextInput(attrs={'class': 'form-control'}),
'description': forms.Textarea(attrs={'class': 'form-control', 'rows': 4}),
'file': forms.FileInput(attrs={
'class': 'form-control',
'accept': '.pdf,.doc,.docx,.txt'
}),
'thumbnail': forms.FileInput(attrs={
'class': 'form-control',
'accept': 'image/*'
}),
}
def clean_file(self):
"""Validate uploaded file"""
file = self.cleaned_data.get('file')
if file:
# Check file size (10MB limit)
if file.size > 10 * 1024 * 1024:
raise ValidationError('File size cannot exceed 10MB.')
# Check file extension
allowed_extensions = ['.pdf', '.doc', '.docx', '.txt']
file_extension = file.name.lower().split('.')[-1]
if f'.{file_extension}' not in allowed_extensions:
raise ValidationError('Only PDF, DOC, DOCX, and TXT files are allowed.')
# Check file content type
allowed_types = [
'application/pdf',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'text/plain'
]
if file.content_type not in allowed_types:
raise ValidationError('Invalid file type.')
return file
def clean_thumbnail(self):
"""Validate thumbnail image"""
thumbnail = self.cleaned_data.get('thumbnail')
if thumbnail:
# Check image size (2MB limit)
if thumbnail.size > 2 * 1024 * 1024:
raise ValidationError('Thumbnail size cannot exceed 2MB.')
# Check image dimensions
if hasattr(thumbnail, 'width') and hasattr(thumbnail, 'height'):
if thumbnail.width > 1920 or thumbnail.height > 1080:
raise ValidationError('Thumbnail dimensions cannot exceed 1920x1080 pixels.')
return thumbnail
def save(self, commit=True):
"""Custom save with file processing"""
document = super().save(commit=False)
# Generate thumbnail if not provided
if document.file and not document.thumbnail:
document.thumbnail = self.generate_thumbnail(document.file)
if commit:
document.save()
return document
def generate_thumbnail(self, file):
"""Generate thumbnail for document (simplified)"""
# This would contain logic to generate thumbnails
# based on file type (PDF preview, document icon, etc.)
return None
# views.py - File upload handling
from django.shortcuts import render, redirect
from django.contrib import messages
from .forms import DocumentForm
def upload_document(request):
if request.method == 'POST':
form = DocumentForm(request.POST, request.FILES)
if form.is_valid():
document = form.save(commit=False)
document.author = request.user
document.save()
messages.success(request, 'Document uploaded successfully!')
return redirect('document_detail', pk=document.pk)
else:
form = DocumentForm()
return render(request, 'documents/upload.html', {'form': form})
# forms.py - Optimized ModelForm
from django import forms
from django.db import transaction
from .models import Article, Category, Tag
class OptimizedArticleForm(forms.ModelForm):
"""Performance-optimized ModelForm"""
class Meta:
model = Article
fields = ['title', 'slug', 'category', 'tags', 'content', 'published']
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Optimize querysets
self.fields['category'].queryset = Category.objects.select_related('parent')
self.fields['tags'].queryset = Tag.objects.only('id', 'name')
@transaction.atomic
def save(self, commit=True):
"""Atomic save operation"""
instance = super().save(commit=commit)
if commit:
# Perform additional operations atomically
self.save_related_data(instance)
return instance
def save_related_data(self, instance):
"""Save related data efficiently"""
# Bulk operations for better performance
if hasattr(self, 'cleaned_data'):
tags = self.cleaned_data.get('tags', [])
if tags:
instance.tags.set(tags)
class BulkArticleForm(forms.Form):
"""Form for bulk operations on articles"""
articles = forms.ModelMultipleChoiceField(
queryset=Article.objects.none(),
widget=forms.CheckboxSelectMultiple
)
action = forms.ChoiceField(choices=[
('publish', 'Publish Selected'),
('unpublish', 'Unpublish Selected'),
('delete', 'Delete Selected'),
])
def __init__(self, *args, **kwargs):
user = kwargs.pop('user', None)
super().__init__(*args, **kwargs)
# Filter articles based on user permissions
if user:
if user.is_staff:
self.fields['articles'].queryset = Article.objects.all()
else:
self.fields['articles'].queryset = Article.objects.filter(author=user)
def perform_bulk_action(self):
"""Perform bulk action on selected articles"""
articles = self.cleaned_data['articles']
action = self.cleaned_data['action']
if action == 'publish':
articles.update(published=True)
elif action == 'unpublish':
articles.update(published=False)
elif action == 'delete':
articles.delete()
return len(articles)
ModelForms provide a powerful bridge between Django models and form handling, automatically generating forms while allowing extensive customization. Understanding ModelForm creation, validation, related model handling, and optimization techniques enables you to build efficient, maintainable forms that seamlessly integrate with your data models while providing robust user experiences.
Form Rendering in Templates
Django provides flexible options for rendering forms in templates, from automatic rendering to complete manual control. Understanding these rendering techniques enables you to create consistent, accessible, and visually appealing forms.
Advanced Form Techniques
Django's form system supports sophisticated patterns for complex user interfaces, including multi-step forms, dynamic field generation, AJAX integration, and custom validation workflows. This chapter covers advanced techniques for building professional-grade form experiences.