Forms and User Input

Model Forms

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.

Model Forms

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.

Basic ModelForm Usage

Simple ModelForm Creation

# 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})

Advanced ModelForm Customization

Field Inclusion and Exclusion

# 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'}),
        }

Custom Field Validation

# 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

Foreign Key and Many-to-Many Fields

# 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

Inline Formsets

# 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,
    })

Dynamic ModelForm Generation

Runtime Form Creation

# 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})

File Upload Handling

ModelForm with File Fields

# 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})

ModelForm Best Practices

Performance Optimization

# 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.