Form Validation and Processing

Module 18: Python Backend - Django

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

graph TD A[User Submits Form] --> B[Field Validation] B --> C{Field Valid?} C -->|No| D[Field Error] C -->|Yes| E[Form Validation] E --> F{Form Valid?} F -->|No| G[Form Error] F -->|Yes| H[Model Validation] H --> I{Model Valid?} I -->|No| J[Model Error] I -->|Yes| K[Save to Database] D --> L[Return Form with Errors] G --> L J --> L

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:

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:

  1. Get the field value from self.cleaned_data
  2. Perform validation checks
  3. Raise ValidationError if validation fails
  4. 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:

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:

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:

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

  1. Custom Validation: Create a job application form with custom validation for education level and experience years based on the job position selected.
  2. 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.
  3. 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.
  4. Dynamic Forms: Create a custom survey form that dynamically adds questions based on the user's previous answers.

Key Takeaways

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.