Introduction

The previous part covered how to structure relationships between models and how to query data efficiently. With that foundation in place, we can now focus on user interaction: accepting input, managing access, and exposing your application through APIs.

This part addresses forms and validation, user authentication, class-based views, and an introduction to Django REST Framework. These are the pieces that turn a data layer into a complete web application.

Forms and Validation

Every web application needs user input, and every piece of user input is a potential problem. Users make typos. Users submit empty fields. Malicious users submit carefully crafted attacks. Django’s form system addresses all of these, but you need to understand what it’s protecting you from to use it correctly.

Why Forms Matter

You could read form data directly from request.POST and process it yourself. This is a mistake for several reasons.

First, everything in request.POST is a string. That “age” field the user filled in? It’s the string “25”, not the integer 25. That checkbox? It’s either present (as a string) or missing entirely. That date? A string in whatever format the user’s browser decided to send. Converting and validating all this manually is tedious and error-prone.

Second, security. Cross-Site Request Forgery (CSRF) attacks trick a user’s browser into submitting a form to your site using their authenticated session. Without protection, an attacker’s website could contain a hidden form that transfers money from your bank account, and your browser would happily send along your session cookies. Django’s CSRF protection requires a token that attackers can’t know, but you get this only through Django’s form handling.

Third, user experience. When validation fails, you need to redisplay the form with error messages next to the relevant fields, preserving the valid data the user already entered. Doing this manually means tracking error state and form values yourself. Django forms handle this automatically.

The Form Lifecycle

Understanding the form lifecycle clarifies how everything fits together.

First, you create a form class that defines fields and their validation rules:

from django import forms

class ContactForm(forms.Form):
    name = forms.CharField(max_length=100)
    email = forms.EmailField()
    message = forms.CharField(widget=forms.Textarea)

Each field type brings its own validation — EmailField checks for a valid email format, CharField checks length constraints, and so on.

In your view, the form exists in two states: unbound (empty, for display) and bound (populated with submitted data, for validation):

from django.shortcuts import render, redirect
from .forms import ContactForm

def contact(request):
    if request.method == 'POST':
        form = ContactForm(request.POST)  # bound form
        if form.is_valid():
            # Access validated, typed data
            name = form.cleaned_data['name']
            email = form.cleaned_data['email']
            # Process it (send email, save to database, etc.)
            return redirect('success')
    else:
        form = ContactForm()  # unbound form

    return render(request, 'blog/contact.html', {'form': form})

The is_valid() call is where the magic happens. Django runs every field’s validation, collects errors, and populates cleaned_data with properly typed values. Only after this call succeeds should you trust the form’s data.

If validation fails, the form object retains the submitted values and error messages. When you render it again, users see what they entered plus specific feedback about what went wrong.

The template renders the form and includes the essential CSRF token:

<form method="post">
    {% csrf_token %}
    {{ form.as_p }}
    <button type="submit">Send</button>
</form>

Never omit {% csrf_token %} from POST forms. It’s your protection against a real class of attacks.

ModelForms: Bridging Models and Input

A large percentage of forms exist to create or update model instances. Writing a form with fields that duplicate your model’s field definitions is tedious and creates maintenance burden — change the model, forget to change the form, introduce bugs.

ModelForm solves this by generating form fields automatically from a model:

from django import forms
from .models import Post

class PostForm(forms.ModelForm):
    class Meta:
        model = Post
        fields = ['title', 'content', 'tags']

Django examines Post’s fields and creates appropriate form fields with matching validation. A CharField becomes a text input. A TextField becomes a textarea. A ManyToManyField becomes a multiple-select widget.

The view can save directly to the database, but often you need to add data the form doesn’t include — like the current user:

def create_post(request):
    if request.method == 'POST':
        form = PostForm(request.POST)
        if form.is_valid():
            post = form.save(commit=False)  # create instance but don't save yet
            post.author = request.user       # set the author ourselves
            post.save()                      # now save to database
            form.save_m2m()                  # save many-to-many relations (like tags)
            return redirect('post_detail', pk=post.pk)
    else:
        form = PostForm()

    return render(request, 'blog/create.html', {'form': form})

The commit=False pattern is important. It creates the model instance in memory without saving, letting you modify it before persistence. The separate save_m2m() call is necessary because many-to-many relationships can’t be saved until the main object has a primary key.

Custom Validation Rules

Built-in field validation handles common cases, but your application has specific rules. Titles must be at least five characters. End dates must follow start dates. The email domain must match your company.

Django provides two hooks for custom validation. Field-level validation uses methods named clean_fieldname:

class PostForm(forms.ModelForm):
    class Meta:
        model = Post
        fields = ['title', 'content']

    def clean_title(self):
        title = self.cleaned_data['title']
        if len(title) < 5:
            raise forms.ValidationError('Title must be at least 5 characters.')
        return title

This method receives the field’s already-converted value, applies additional checks, and either returns the (possibly modified) value or raises ValidationError. The error automatically associates with that field.

For validation involving multiple fields, override the clean method:

    def clean(self):
        cleaned_data = super().clean()
        title = cleaned_data.get('title', '')
        content = cleaned_data.get('content', '')

        if title and title.lower() in content.lower():
            raise forms.ValidationError('Content should not simply repeat the title.')

        return cleaned_data

Errors raised here become “non-field errors” — they appear at the top of the form rather than next to a specific field, which is appropriate for rules spanning multiple fields.

The validation order matters: field-level cleaning happens first, then cross-field cleaning. If a field fails its individual validation, it won’t appear in cleaned_data during the clean method, which is why we use get() with defaults.

User Authentication

Almost every web application needs to know who’s using it. Django provides a complete authentication system out of the box — login, logout, password management, and permission checking. Using it correctly means understanding both what it does and what decisions it leaves to you.

What You Get for Free

Django’s authentication system includes more than beginners realize:

A User model with secure password hashing. Passwords are never stored in plain text — Django uses PBKDF2 by default, with configurable algorithms. This matters enormously: database breaches happen, and properly hashed passwords remain protected.

Session management. HTTP is stateless; each request is independent. Sessions create continuity by storing data server-side and giving browsers a cookie to identify themselves. When a user logs in, Django creates a session, and subsequent requests carry that session’s identity.

Views for login, logout, and password management. You don’t write authentication logic from scratch; you configure existing, tested code.

A permissions framework. Beyond “logged in” versus “anonymous,” Django supports granular permissions per model and per user.

All this is security-critical code. Authentication bugs lead to breaches, and authentication is notoriously hard to implement correctly. Using Django’s battle-tested implementation eliminates vulnerability categories that affect hand-rolled systems.

Setting Up Login and Logout

Django provides views for login and logout that handle the mechanics correctly. You just need to wire them up and provide templates:

from django.contrib.auth import views as auth_views

urlpatterns = [
    path('login/', auth_views.LoginView.as_view(), name='login'),
    path('logout/', auth_views.LogoutView.as_view(), name='logout'),
]

The login view expects a template at templates/registration/login.html:

<h1>Login</h1>

{% if form.errors %}
    <p class="error">Invalid username or password.</p>
{% endif %}

<form method="post">
    {% csrf_token %}
    {{ form.as_p }}
    <button type="submit">Login</button>
</form>

Notice that error messages are intentionally vague — “Invalid username or password” rather than “That username doesn’t exist” or “Wrong password.” This is deliberate security practice: telling attackers which part failed helps them enumerate valid usernames.

Configure where users go after authentication in settings.py:

LOGIN_REDIRECT_URL = '/'       # where to go after successful login
LOGOUT_REDIRECT_URL = '/'      # where to go after logout
LOGIN_URL = '/login/'          # where to send unauthenticated users

These settings matter for user experience. Someone who logs in should land somewhere useful, not on a generic success page.

User Registration

Interestingly, Django doesn’t include a registration view — only a form. This is intentional: registration requirements vary too much between applications. Some need email verification. Some need admin approval. Some integrate with external identity providers.

For basic registration, the built-in form works fine:

from django.contrib.auth.forms import UserCreationForm
from django.shortcuts import render, redirect

def register(request):
    if request.method == 'POST':
        form = UserCreationForm(request.POST)
        if form.is_valid():
            form.save()
            return redirect('login')
    else:
        form = UserCreationForm()

    return render(request, 'registration/register.html', {'form': form})

This creates a user with username and password. For additional fields like email, you’d extend the form or create a custom one — a common first customization.

Protecting Views

The @login_required decorator is your first line of access control. It redirects unauthenticated users to the login page, then brings them back after successful authentication:

from django.contrib.auth.decorators import login_required

@login_required
def create_post(request):
    # Only logged-in users reach this code
    ...

The redirect behavior is seamless — Django remembers where the user was trying to go and returns them there after login. This matters for user experience: bookmarked URLs to protected pages should work after authentication, not dump users on the homepage.

Templates often need to show different content to authenticated versus anonymous users:

{% if user.is_authenticated %}
    <p>Welcome, {{ user.username }}!</p>
    <a href="{% url 'logout' %}">Logout</a>
{% else %}
    <a href="{% url 'login' %}">Login</a>
{% endif %}

The user variable is automatically available in templates thanks to Django’s context processors. Anonymous visitors get an AnonymousUser object where is_authenticated returns False.

For more granular control, Django’s permission system lets you check specific capabilities:

from django.contrib.auth.decorators import permission_required

@permission_required('blog.add_post')
def create_post(request):
    ...

Each model automatically gets add, change, delete, and view permissions. You can also define custom permissions for domain-specific actions. Checking permissions early in development — even if initially everyone has all permissions — makes adding proper access control later far easier.

Class-Based Views

Django offers two ways to write views: functions and classes. This isn’t a matter of one being better — they solve different problems, and understanding when each excels helps you write clearer code.

Function-based views are explicit. The entire request-handling logic is visible in one place, line by line. For simple views or unusual flows that don’t match common patterns, functions are often clearer. You don’t need to know what methods to override or how inheritance works.

Class-based views excel at common patterns. Listing objects with pagination. Displaying a single object’s details. Creating or updating through a form. These patterns repeat across every Django application, and generic class-based views implement them with minimal code. The tradeoff is indirection — understanding what happens requires knowing which methods the parent class calls and when.

Common Generic Views

The most-used generic views handle CRUD operations:

from django.views.generic import ListView, DetailView, CreateView
from django.contrib.auth.mixins import LoginRequiredMixin
from django.urls import reverse_lazy
from .models import Post

class PostListView(ListView):
    model = Post
    template_name = 'blog/post_list.html'
    context_object_name = 'posts'
    ordering = ['-created_at']
    paginate_by = 10

class PostDetailView(DetailView):
    model = Post
    template_name = 'blog/post_detail.html'

class PostCreateView(LoginRequiredMixin, CreateView):
    model = Post
    fields = ['title', 'content', 'tags']
    template_name = 'blog/post_form.html'
    success_url = reverse_lazy('post_list')

    def form_valid(self, form):
        form.instance.author = self.request.user
        return super().form_valid(form)

ListView handles fetching objects, ordering, and pagination automatically. You specify what model and how many per page; Django does the rest. DetailView fetches a single object based on the URL’s primary key. CreateView displays a form and saves valid submissions.

The form_valid override in CreateView demonstrates customization: we need to set the author before saving, so we hook into the “form is valid, about to save” moment.

Wire them to URLs by calling .as_view():

urlpatterns = [
    path('', PostListView.as_view(), name='post_list'),
    path('post/<int:pk>/', PostDetailView.as_view(), name='post_detail'),
    path('post/new/', PostCreateView.as_view(), name='post_create'),
]

Note how LoginRequiredMixin replaces @login_required for class-based views. Mixins add behavior through inheritance, and they must come before the view class in the inheritance list. LoginRequiredMixin, CreateView means “add login requirement to CreateView”; reversing the order breaks it.

The learning curve is real. Class-based views require understanding inheritance, method resolution order, and which methods to override. But once you’re comfortable, expressing “list these objects with pagination” in three lines beats writing the same boilerplate repeatedly.

Introduction to Django REST Framework

Modern web development often separates frontend and backend. The backend becomes an API that serves JSON; the frontend — whether a React application, mobile app, or third-party integration — consumes that API and handles presentation.

Django alone can serve JSON responses, but Django REST Framework (DRF) provides structure for API development: serialization, authentication, permissions, browsable documentation, and conventions that keep APIs consistent.

Installation and Setup

DRF is a separate package:

pip install djangorestframework

Add it to your installed apps:

INSTALLED_APPS = [
    ...
    'rest_framework',
]

Serializers: Translating Between Python and JSON

Serializers are to APIs what forms are to HTML: they define how data enters and leaves your application.

A serializer specifies which model fields become JSON and how incoming JSON maps to model fields. Validation happens here too — you can reject malformed requests before they reach your database.

from rest_framework import serializers
from .models import Post

class PostSerializer(serializers.ModelSerializer):
    author = serializers.ReadOnlyField(source='author.username')

    class Meta:
        model = Post
        fields = ['id', 'title', 'content', 'author', 'created_at']

The ReadOnlyField for author deserves explanation. We want to display the author’s username in responses, but we don’t want users setting the author through the API — that should come from authentication. ReadOnlyField shows data in responses but ignores it in requests.

API Views

DRF provides generic views similar to Django’s, tailored for APIs:

from rest_framework import generics
from rest_framework.permissions import IsAuthenticatedOrReadOnly
from .models import Post
from .serializers import PostSerializer

class PostListCreateAPIView(generics.ListCreateAPIView):
    queryset = Post.objects.all()
    serializer_class = PostSerializer
    permission_classes = [IsAuthenticatedOrReadOnly]

    def perform_create(self, serializer):
        serializer.save(author=self.request.user)

This single class handles two HTTP methods: GET returns a list of posts; POST creates a new one. The permission class IsAuthenticatedOrReadOnly allows anyone to read but requires authentication to create — a common API pattern.

The perform_create override sets the author from the authenticated user, similar to what we did in the form-based view.

URL configuration looks familiar:

urlpatterns = [
    path('api/posts/', PostListCreateAPIView.as_view(), name='api_posts'),
]

One of DRF’s most useful features: visit /api/posts/ in a browser, and you see a browsable interface. You can view data, submit POST requests, and see responses formatted nicely. This makes development and debugging far easier than staring at raw JSON.

What Comes Next

This part covered user interaction: forms for input, authentication for access control, class-based views for common patterns, and APIs for modern architectures. Combined with the previous part on models and queries, you now have the tools to build complete Django applications.

The third and final part will address production concerns: serving static files, configuring databases properly, security settings you shouldn’t forget, testing strategies, and deployment to real servers.

Conclusion - Staff Note

The pieces covered here work together. Forms validate input before it reaches models. Authentication determines who can submit those forms. Class-based views orchestrate the flow. APIs expose the same logic to different clients.

Don’t try to memorize syntax. Focus on understanding the underlying ideas: validation happens in phases with different responsibilities; permissions should be considered early even if initially permissive; class-based views trade explicitness for conciseness.

You’ll forget {% csrf_token %} and wonder why forms don’t submit. You’ll mix up when to use @login_required versus LoginRequiredMixin. These mistakes are part of learning, and each one deepens your understanding.

Build something. A small application where users must log in to create posts, where forms validate input properly, where an API lets a mobile app access the same data. The concepts solidify through use.


The Frontek.dev Team