The Importance of Validation
Form validation is a critical aspect of web development that ensures data integrity, enhances user experience, and protects your application from security vulnerabilities. Django provides a powerful validation system that happens at multiple levels.
In this lecture, we'll explore Django's validation mechanisms and learn how to implement custom validation rules to meet specific business requirements.
Analogy: The Quality Control Inspector
Think of form validation as a quality control inspector in a manufacturing plant. Before a product (user data) reaches the assembly line (your database), it passes through multiple inspection stations:
- Field-level validation: Inspectors checking individual components
- Form-level validation: Engineers ensuring components work together
- Model-level validation: Final quality control before shipping
Each station can catch different types of defects, with the combined system ensuring only high-quality products reach the customer.
Django's Validation Process
When you call form.is_valid(), Django triggers a cascade of validation checks. Let's explore each
level in detail.
Field-Level Validation
Each form field has built-in validation based on its type. For example:
CharFieldenforces minimum and maximum lengthEmailFieldensures the input follows email formatIntegerFieldchecks that the value is a valid integerURLFieldvalidates that the input is a valid URL
Field Validation Parameters
# Example of built-in field validation
from django import forms
class ProductForm(forms.Form):
name = forms.CharField(
min_length=3,
max_length=100,
required=True,
error_messages={
'required': 'Please enter a product name',
'min_length': 'Name must be at least 3 characters',
}
)
price = forms.DecimalField(
min_value=0.01,
max_value=99999.99,
decimal_places=2,
required=True
)
quantity = forms.IntegerField(
min_value=1,
required=True
)
description = forms.CharField(
required=False,
widget=forms.Textarea,
max_length=1000
)
category = forms.ChoiceField(
choices=[
('electronics', 'Electronics'),
('clothing', 'Clothing'),
('books', 'Books'),
('home', 'Home & Kitchen'),
],
required=True
)
Custom Field Validators
For more complex validation beyond the built-in options, you can create custom validators:
# Custom validator functions
from django.core.exceptions import ValidationError
from django import forms
def validate_even(value):
if value % 2 != 0:
raise ValidationError(
'%(value)s is not an even number',
params={'value': value},
)
def validate_contains_django(value):
if 'django' not in value.lower():
raise ValidationError(
'The text must contain the word "django"'
)
# Using custom validators in a form
class EventForm(forms.Form):
name = forms.CharField(max_length=100)
num_attendees = forms.IntegerField(validators=[validate_even])
description = forms.CharField(
widget=forms.Textarea,
validators=[validate_contains_django]
)
Validators are simple functions that raise a ValidationError when the value doesn't meet
the requirements.
Custom Field Cleaning Methods
For more complex field validation, you can define custom clean_<fieldname> methods:
class SignupForm(forms.Form):
username = forms.CharField(max_length=30)
password1 = forms.CharField(widget=forms.PasswordInput)
password2 = forms.CharField(widget=forms.PasswordInput, label="Confirm password")
email = forms.EmailField()
def clean_username(self):
username = self.cleaned_data.get('username')
# Check if username contains only alphanumeric characters
if not username.isalnum():
raise ValidationError("Username must contain only letters and numbers")
# Check if username already exists
if User.objects.filter(username=username).exists():
raise ValidationError("This username is already taken")
return username
def clean_email(self):
email = self.cleaned_data.get('email')
# Check if email domain is allowed
domain = email.split('@')[1]
allowed_domains = ['gmail.com', 'yahoo.com', 'outlook.com', 'example.com']
if domain not in allowed_domains:
raise ValidationError(
f"Email domain {domain} is not allowed. Please use one of: {', '.join(allowed_domains)}"
)
# Check if email already exists
if User.objects.filter(email=email).exists():
raise ValidationError("This email is already registered")
return email
The clean_<fieldname> method should:
- Get the field value from
self.cleaned_data - Perform validation checks
- Raise
ValidationErrorif validation fails - Return the cleaned value (which might be transformed)
Form-Level Validation
Sometimes you need to validate fields in relation to each other. For this, you can override the form's
clean() method:
class PasswordChangeForm(forms.Form):
old_password = forms.CharField(widget=forms.PasswordInput)
new_password1 = forms.CharField(widget=forms.PasswordInput, label="New password")
new_password2 = forms.CharField(widget=forms.PasswordInput, label="Confirm new password")
def clean(self):
cleaned_data = super().clean()
old_password = cleaned_data.get('old_password')
new_password1 = cleaned_data.get('new_password1')
new_password2 = cleaned_data.get('new_password2')
# Check if new passwords match
if new_password1 and new_password2 and new_password1 != new_password2:
# Add error to a specific field
self.add_error('new_password2', "The two password fields didn't match")
# Check if new password is different from old password
if old_password and new_password1 and old_password == new_password1:
# Add a form-level error (not tied to a specific field)
raise ValidationError("New password cannot be the same as the old password")
# Password strength validation
if new_password1 and len(new_password1) < 8:
self.add_error('new_password1', "Password must be at least 8 characters long")
return cleaned_data
The clean() method provides access to all field values at once, making it perfect for:
- Comparing multiple fields (like password confirmation)
- Conditional validation based on other field values
- Complex business rules that span multiple fields
Model-Level Validation
For ModelForm classes, there's an additional layer of validation from the model itself:
# models.py
from django.db import models
from django.core.exceptions import ValidationError
from django.core.validators import MinValueValidator, MaxValueValidator
import datetime
def validate_future_date(value):
if value < datetime.date.today():
raise ValidationError('Date cannot be in the past')
class Event(models.Model):
title = models.CharField(max_length=200)
date = models.DateField(validators=[validate_future_date])
max_attendees = models.IntegerField(
validators=[MinValueValidator(5), MaxValueValidator(1000)]
)
def clean(self):
super().clean()
# Example of model-level validation
if self.title and self.title.lower() == 'test':
raise ValidationError({
'title': 'Event title cannot simply be "test"'
})
# Check for conflicting events
if self.date:
same_day_events = Event.objects.filter(date=self.date)
if self.pk: # Exclude self if updating
same_day_events = same_day_events.exclude(pk=self.pk)
if same_day_events.count() >= 3:
raise ValidationError({
'date': 'Cannot schedule more than 3 events on the same day'
})
# forms.py
from django import forms
from .models import Event
class EventForm(forms.ModelForm):
class Meta:
model = Event
fields = ['title', 'date', 'max_attendees']
# You can still add form-level validation
def clean(self):
cleaned_data = super().clean()
# Additional validation here
return cleaned_data
When using ModelForm, Django calls both the form's clean() method and the model's
clean() method during validation.
Handling Validation Errors
Django's form system automatically collects validation errors and makes them available in templates:
# views.py
from django.shortcuts import render, redirect
from .forms import EventForm
def create_event(request):
if request.method == 'POST':
form = EventForm(request.POST)
if form.is_valid():
form.save()
return redirect('event_list')
else:
form = EventForm()
return render(request, 'events/create.html', {'form': form})
# templates/events/create.html
<form method="post">
{% csrf_token %}
{% if form.non_field_errors %}
<div class="alert alert-danger">
{% for error in form.non_field_errors %}
{{ error }}
{% endfor %}
</div>
{% endif %}
{% for field in form %}
<div class="form-group">
{{ field.label_tag }}
{{ field }}
{% if field.errors %}
<div class="invalid-feedback d-block">
{% for error in field.errors %}
{{ error }}
{% endfor %}
</div>
{% endif %}
{% if field.help_text %}
<small class="form-text text-muted">
{{ field.help_text }}
</small>
{% endif %}
</div>
{% endfor %}
<button type="submit" class="btn btn-primary">Create Event</button>
</form>
Errors come in two types:
- Field errors: Accessed via
{{ field.errors }}in templates - Non-field errors: Form-level errors accessed via
{{ form.non_field_errors }}
Advanced Form Processing
Processing Multiple Forms
# views.py
def event_registration(request):
if request.method == 'POST':
# Create both forms with submitted data
event_form = EventForm(request.POST)
attendee_form = AttendeeForm(request.POST)
# Check if both forms are valid
if event_form.is_valid() and attendee_form.is_valid():
# Save event first to get its ID
event = event_form.save()
# Attach event to attendee before saving
attendee = attendee_form.save(commit=False)
attendee.event = event
attendee.save()
return redirect('registration_success')
else:
event_form = EventForm()
attendee_form = AttendeeForm()
return render(request, 'events/registration.html', {
'event_form': event_form,
'attendee_form': attendee_form,
})
FormSets: Working with Multiple Instances of the Same Form
# Using formsets for multiple instances
from django.forms import formset_factory, modelformset_factory
from .forms import AttendeeForm
from .models import Attendee
# views.py
def register_group(request, event_id):
event = Event.objects.get(pk=event_id)
# Create a formset with 3 empty forms
AttendeeFormSet = formset_factory(AttendeeForm, extra=3)
if request.method == 'POST':
formset = AttendeeFormSet(request.POST)
if formset.is_valid():
for form in formset:
if form.has_changed(): # Only process forms that have data
attendee = form.save(commit=False)
attendee.event = event
attendee.save()
return redirect('registration_success')
else:
formset = AttendeeFormSet()
return render(request, 'events/group_registration.html', {
'formset': formset,
'event': event,
})
# templates/events/group_registration.html
<form method="post">
{% csrf_token %}
{{ formset.management_form }}
<h2>Register for {{ event.title }}</h2>
{% for form in formset %}
<div class="attendee-form">
<h3>Attendee {{ forloop.counter }}</h3>
{{ form.as_p }}
</div>
{% endfor %}
<button type="submit" class="btn btn-primary">Register Group</button>
</form>
FormSets are powerful for handling cases like:
- Adding multiple products to an order at once
- Creating several related records simultaneously
- Editing a collection of similar items
Dynamic Form Fields
# Generating form fields dynamically based on data
class SurveyForm(forms.Form):
def __init__(self, *args, survey=None, **kwargs):
super().__init__(*args, **kwargs)
if survey:
# Get all questions for this survey
questions = Question.objects.filter(survey=survey)
for question in questions:
field_name = f'question_{question.id}'
# Create different field types based on question type
if question.type == 'text':
self.fields[field_name] = forms.CharField(
label=question.text,
required=question.required
)
elif question.type == 'number':
self.fields[field_name] = forms.IntegerField(
label=question.text,
required=question.required
)
elif question.type == 'choice':
choices = [(c.id, c.text) for c in question.choices.all()]
self.fields[field_name] = forms.ChoiceField(
label=question.text,
choices=choices,
required=question.required,
widget=forms.RadioSelect
)
Real-World Example: Product Review System
Let's build a complete product review system with validation and processing:
# models.py
from django.db import models
from django.contrib.auth.models import User
from django.core.validators import MinValueValidator, MaxValueValidator
class Product(models.Model):
name = models.CharField(max_length=200)
description = models.TextField()
price = models.DecimalField(max_digits=10, decimal_places=2)
def __str__(self):
return self.name
class Review(models.Model):
product = models.ForeignKey(Product, on_delete=models.CASCADE, related_name='reviews')
user = models.ForeignKey(User, on_delete=models.CASCADE)
rating = models.IntegerField(
validators=[MinValueValidator(1), MaxValueValidator(5)]
)
title = models.CharField(max_length=100)
comment = models.TextField()
pros = models.TextField(blank=True)
cons = models.TextField(blank=True)
verified_purchase = models.BooleanField(default=False)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
unique_together = ('product', 'user') # One review per user per product
def __str__(self):
return f"{self.rating} stars for {self.product.name} by {self.user.username}"
def clean(self):
super().clean()
if self.pros and len(self.pros.split()) < 3:
raise ValidationError({
'pros': 'Please write at least 3 words for pros section'
})
if self.cons and len(self.cons.split()) < 3:
raise ValidationError({
'cons': 'Please write at least 3 words for cons section'
})
# forms.py
from django import forms
from .models import Review
import datetime
class ReviewForm(forms.ModelForm):
confirm_terms = forms.BooleanField(
required=True,
label="I confirm this is my honest opinion and I have used this product"
)
class Meta:
model = Review
fields = ['rating', 'title', 'comment', 'pros', 'cons']
widgets = {
'comment': forms.Textarea(attrs={'rows': 5}),
'pros': forms.Textarea(attrs={'rows': 3, 'placeholder': 'What did you like about this product?'}),
'cons': forms.Textarea(attrs={'rows': 3, 'placeholder': 'What could be improved?'}),
}
help_texts = {
'rating': 'Rate this product from 1 to 5 stars',
'title': 'Summarize your review in a short title',
}
def clean_title(self):
title = self.cleaned_data.get('title')
# Check for all caps titles
if title and title.isupper():
raise ValidationError("Please don't use ALL CAPS in your title")
return title
def clean_comment(self):
comment = self.cleaned_data.get('comment')
# Check minimum length
if comment and len(comment.split()) < 10:
raise ValidationError("Your review must be at least 10 words long")
# Check for banned words (simplified example)
banned_words = ['fake', 'scam', 'awful']
for word in banned_words:
if word in comment.lower():
raise ValidationError(f"Your review contains inappropriate language ('{word}')")
return comment
def clean(self):
cleaned_data = super().clean()
rating = cleaned_data.get('rating')
comment = cleaned_data.get('comment')
cons = cleaned_data.get('cons')
# If rating is low, make sure cons are provided
if rating and rating <= 2 and not cons:
self.add_error('cons', "Please explain what you didn't like about the product")
# Check for review balance
if rating and comment:
if rating >= 4 and 'but' in comment.lower() and len(comment.split('but')[1].split()) > len(comment.split()) / 2:
self.add_error(
'comment',
"Your high rating doesn't seem to match your mostly negative comments"
)
return cleaned_data
# views.py
from django.shortcuts import render, redirect, get_object_or_404
from django.contrib.auth.decorators import login_required
from django.contrib import messages
from .models import Product, Review
from .forms import ReviewForm
@login_required
def add_review(request, product_id):
product = get_object_or_404(Product, pk=product_id)
# Check if user already reviewed this product
existing_review = Review.objects.filter(product=product, user=request.user).first()
if existing_review:
messages.warning(request, "You've already reviewed this product. You can edit your existing review.")
return redirect('edit_review', review_id=existing_review.id)
# Check if user purchased the product (in a real system, this would check order history)
verified_purchase = True # Simplified for example
if request.method == 'POST':
form = ReviewForm(request.POST)
if form.is_valid():
review = form.save(commit=False)
review.product = product
review.user = request.user
review.verified_purchase = verified_purchase
review.save()
messages.success(request, "Your review has been submitted. Thank you for your feedback!")
return redirect('product_detail', product_id=product.id)
else:
form = ReviewForm()
return render(request, 'reviews/add_review.html', {
'form': form,
'product': product,
'verified_purchase': verified_purchase,
})
Practice Activities
- Custom Validation: Create a job application form with custom validation for education level and experience years based on the job position selected.
- Cross-Field Validation: Build a travel booking form that validates that the return date is after the departure date, and that the number of passengers fits the room type selected.
- FormSet Challenge: Create an order form with a formset for multiple order items, validating that at least one item is ordered and that quantities are positive.
- Dynamic Forms: Create a custom survey form that dynamically adds questions based on the user's previous answers.
Key Takeaways
- Form validation happens at multiple levels: field, form, and model
- Custom validation allows you to implement complex business rules
- Django's validation system helps ensure data integrity before it reaches your database
- FormSets are powerful tools for handling multiple instances of the same form
- Dynamic forms can be created to adapt to different user inputs or application states
Mastering form validation and processing is essential for building robust web applications. By implementing proper validation at all levels, you can provide a great user experience while protecting your application from bad data.
In our next lecture, we'll explore Django's powerful admin interface and learn how to customize it for your specific application needs.