Django从零开始(五):表单处理与数据验证

一、Django表单概述

Django表单(Forms)系统是框架的核心组件之一,它负责处理最常见的Web开发任务:接收用户输入、验证数据、清洗数据、显示错误信息。Django表单与HTML表单不同,它是一个Python类,在服务器端处理数据。

Django表单的三大功能

  • 渲染HTML:自动生成表单的HTML标签
  • 数据验证:服务端验证用户提交的数据
  • 数据清洗:将原始输入转换为Python数据类型

二、Form类基本使用

2.1 定义表单类

# blog/forms.py

from django import forms
from .models import Article, Category, Comment


class ContactForm(forms.Form):
    """联系表单(不关联模型)"""
    name = forms.CharField(
        label='姓名',
        max_length=100,
        widget=forms.TextInput(attrs={
            'class': 'form-control',
            'placeholder': '请输入您的姓名',
        })
    )
    email = forms.EmailField(
        label='邮箱',
        widget=forms.EmailInput(attrs={
            'class': 'form-control',
            'placeholder': '请输入您的邮箱',
        })
    )
    subject = forms.CharField(
        label='主题',
        max_length=200,
        widget=forms.TextInput(attrs={'class': 'form-control'})
    )
    message = forms.CharField(
        label='留言内容',
        widget=forms.Textarea(attrs={
            'class': 'form-control',
            'rows': 5,
            'placeholder': '请输入留言内容',
        })
    )
    priority = forms.ChoiceField(
        label='优先级',
        choices=[
            ('low', '低'),
            ('medium', '中'),
            ('high', '高'),
        ],
        initial='medium',
        widget=forms.Select(attrs={'class': 'form-control'})
    )
    attachment = forms.FileField(
        label='附件',
        required=False,
        widget=forms.ClearableFileInput(attrs={'class': 'form-control'})
    )

2.2 在视图中使用表单

# blog/views.py

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

def contact(request):
    if request.method == 'POST':
        form = ContactForm(request.POST, request.FILES)  # 绑定提交数据
        if form.is_valid():
            # 获取清洗后的数据
            name = form.cleaned_data['name']
            email = form.cleaned_data['email']
            subject = form.cleaned_data['subject']
            message = form.cleaned_data['message']
            attachment = form.cleaned_data.get('attachment')

            # 处理数据(发送邮件等)
            send_mail(
                subject=f'[联系我们] {subject}',
                message=f'来自 {name} ({email}):\n\n{message}',
                from_email=email,
                recipient_list=['admin@example.com'],
            )

            return redirect('blog:contact_success')
    else:
        form = ContactForm()  # 未绑定表单(显示空表单)

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

2.3 在模板中渲染表单

<!-- blog/templates/blog/contact.html -->
{% extends "base.html" %}

{% block content %}
<form method="post" enctype="multipart/form-data">
    {% csrf_token %}

    {# 方式1:快速渲染整个表单 #}
    {{ form.as_p }}

    {# 方式2:按字段手动渲染 #}
    <div class="form-group">
        {{ form.name.label_tag }}
        {{ form.name }}
        {% if form.name.errors %}
        <div class="invalid-feedback">
            {% for error in form.name.errors %}
            <span>{{ error }}</span>
            {% endfor %}
        </div>
        {% endif %}
        {% if form.name.help_text %}
        <small class="form-text text-muted">{{ form.name.help_text }}</small>
        {% endif %}
    </div>

    {# 方式3:完全手动渲染 #}
    <div class="form-group">
        <label for="{{ form.email.id_for_label }}">邮箱</label>
        <input type="email"
               name="{{ form.email.html_name }}"
               id="{{ form.email.id_for_label }}"
               class="form-control {% if form.email.errors %}is-invalid{% endif %}"
               value="{{ form.email.value|default:'' }}"
               placeholder="请输入邮箱">
        {% for error in form.email.errors %}
        <div class="invalid-feedback">{{ error }}</div>
        {% endfor %}
    </div>

    <button type="submit" class="btn btn-primary">提交</button>
</form>
{% endblock %}

三、ModelForm(模型表单)

ModelForm是Django最常用的表单类型,它直接从模型类自动生成表单字段,大幅减少重复代码。

# blog/forms.py

class ArticleForm(forms.ModelForm):
    """文章模型表单"""

    class Meta:
        model = Article
        fields = ['title', 'category', 'tags', 'content', 'summary', 'status']
        # 或者排除某些字段
        # exclude = ['author', 'views', 'created_at', 'updated_at']

        widgets = {
            'title': forms.TextInput(attrs={
                'class': 'form-control',
                'placeholder': '请输入文章标题',
            }),
            'content': forms.Textarea(attrs={
                'class': 'form-control markdown-editor',
                'rows': 15,
            }),
            'summary': forms.Textarea(attrs={
                'class': 'form-control',
                'rows': 3,
                'placeholder': '文章摘要(可选)',
            }),
            'category': forms.Select(attrs={
                'class': 'form-control',
            }),
            'tags': forms.CheckboxSelectMultiple(),
            'status': forms.Select(attrs={
                'class': 'form-control',
            }),
        }

        labels = {
            'title': '文章标题',
            'content': '文章内容',
            'category': '所属分类',
            'tags': '标签',
            'summary': '摘要',
            'status': '发布状态',
        }

        help_texts = {
            'title': '标题将自动生成URL别名',
            'summary': '留空则自动截取正文前200字',
        }

    def clean_title(self):
        """自定义标题验证"""
        title = self.cleaned_data.get('title', '')
        if len(title) < 5:
            raise forms.ValidationError('标题至少需要5个字符')
        # 检查标题是否重复
        if Article.objects.filter(title=title).exclude(pk=self.instance.pk).exists():
            raise forms.ValidationError('该标题已存在,请使用其他标题')
        return title

    def clean(self):
        """全局验证(涉及多个字段)"""
        cleaned_data = super().clean()
        status = cleaned_data.get('status')
        category = cleaned_data.get('category')
        content = cleaned_data.get('content', '')

        # 已发布文章必须有分类
        if status == 'published' and not category:
            self.add_error('category', '发布文章必须选择分类')

        # 已发布文章内容不少于100字
        if status == 'published' and len(content) < 100:
            self.add_error('content', '已发布文章内容不少于100字')

        return cleaned_data


class CommentForm(forms.ModelForm):
    """评论表单"""
    class Meta:
        model = Comment
        fields = ['nickname', 'email', 'content']
        widgets = {
            'nickname': forms.TextInput(attrs={
                'class': 'form-control',
                'placeholder': '您的昵称',
            }),
            'email': forms.EmailInput(attrs={
                'class': 'form-control',
                'placeholder': '您的邮箱',
            }),
            'content': forms.Textarea(attrs={
                'class': 'form-control',
                'rows': 4,
                'placeholder': '写下您的评论...',
            }),
        }

四、表单字段类型与验证器

4.1 常用字段类型

# 字符串字段
CharField(max_length=100, min_length=2)
EmailField()                    # 自动验证邮箱格式
URLField()                      # 自动验证URL格式
SlugField()                     # 只允许字母、数字、连字符、下划线
RegexField(regex=r'^\d{11}$')   # 正则验证(如手机号)
UUIDField()                     # UUID格式验证

# 数值字段
IntegerField(min_value=0, max_value=100)
FloatField()
DecimalField(max_digits=10, decimal_places=2)

# 布尔字段
BooleanField(required=True)     # 必须勾选(如同意协议)
NullBooleanField()              # 可以为None

# 日期时间字段
DateField(widget=forms.DateInput(attrs={'type': 'date'}))
TimeField()
DateTimeField(widget=forms.DateTimeInput(attrs={'type': 'datetime-local'}))

# 选择字段
ChoiceField(choices=[('a', 'A'), ('b', 'B')])
TypedChoiceField(choices=[(1, '一'), (2, '二')], coerce=int)
MultipleChoiceField(choices=[...])

# 文件字段
FileField()
ImageField()                    # 需要安装Pillow
FilePathField(path='/some/path')

# 隐藏字段
HiddenField()

4.2 内置验证器

from django.core.validators import (
    MinValueValidator, MaxValueValidator,
    MinLengthValidator, MaxLengthValidator,
    RegexValidator, EmailValidator, URLValidator,
)

age = forms.IntegerField(
    validators=[
        MinValueValidator(0, '年龄不能为负数'),
        MaxValueValidator(150, '年龄不能超过150'),
    ]
)

phone = forms.CharField(
    validators=[
        RegexValidator(
            regex=r'^1[3-9]\d{9}$',
            message='请输入有效的手机号',
            code='invalid_phone'
        )
    ]
)

password = forms.CharField(
    min_length=8,
    validators=[
        RegexValidator(r'[A-Z]', '密码必须包含大写字母'),
        RegexValidator(r'[a-z]', '密码必须包含小写字母'),
        RegexValidator(r'\d', '密码必须包含数字'),
        RegexValidator(r'[!@#$%^&*]', '密码必须包含特殊字符'),
    ],
    widget=forms.PasswordInput,
    help_text='密码至少8位,需包含大小写字母、数字和特殊字符'
)

五、自定义验证

5.1 字段级验证(clean_字段名)

class RegistrationForm(forms.Form):
    username = forms.CharField(max_length=50)
    email = forms.EmailField()
    password = forms.CharField(widget=forms.PasswordInput)
    confirm_password = forms.CharField(widget=forms.PasswordInput)

    def clean_username(self):
        """验证用户名是否已存在"""
        username = self.cleaned_data.get('username')
        if User.objects.filter(username=username).exists():
            raise forms.ValidationError('该用户名已被注册')
        if username.lower() in ['admin', 'root', 'system']:
            raise forms.ValidationError('该用户名不可使用')
        return username

    def clean_email(self):
        """验证邮箱"""
        email = self.cleaned_data.get('email')
        if User.objects.filter(email=email).exists():
            raise forms.ValidationError('该邮箱已被注册')
        # 只允许特定域名
        domain = email.split('@')[1]
        if domain not in ['gmail.com', 'qq.com', '163.com']:
            raise forms.ValidationError('只支持gmail/qq/163邮箱')
        return email

5.2 表单级验证(clean)

    def clean(self):
        """全局验证"""
        cleaned_data = super().clean()
        password = cleaned_data.get('password')
        confirm_password = cleaned_data.get('confirm_password')

        if password and confirm_password and password != confirm_password:
            # raise 会同时显示在两个字段上
            raise forms.ValidationError('两次输入的密码不一致')
            # 或者添加到特定字段
            self.add_error('confirm_password', '密码不匹配')

        return cleaned_data

5.3 自定义验证器函数

# validators.py

import re
from django.core.exceptions import ValidationError

def validate_chinese(value):
    """验证是否为中文字符"""
    if not re.match(r'^[\u4e00-\u9fa5]+$', value):
        raise ValidationError('请输入中文字符')

def validate_phone(value):
    """验证中国手机号"""
    if not re.match(r'^1[3-9]\d{9}$', value):
        raise ValidationError('%(value)s 不是有效的手机号', params={'value': value})

def validate_file_size(value):
    """验证文件大小不超过5MB"""
    if value.size > 5 * 1024 * 1024:
        raise ValidationError('文件大小不能超过5MB')

def validate_file_extension(value):
    """验证文件扩展名"""
    import os
    ext = os.path.splitext(value.name)[1].lower()
    allowed = ['.jpg', '.jpeg', '.png', '.gif', '.pdf', '.doc', '.docx']
    if ext not in allowed:
        raise ValidationError(f'不支持的文件类型: {ext}')

# 在表单中使用
class UploadForm(forms.Form):
    name = forms.CharField(validators=[validate_chinese])
    phone = forms.CharField(validators=[validate_phone])
    file = forms.FileField(validators=[validate_file_size, validate_file_extension])

六、表单集(Formsets)

表单集用于一次处理多个表单,常见场景如批量编辑、问卷调查等:

# 创建表单集
from django.forms import formset_factory, modelformset_factory

# 基于Form的表单集
ArticleFormSet = formset_factory(ContactForm, extra=3, max_num=10)

# 基于ModelForm的表单集
ArticleModelFormSet = modelformset_factory(
    Article,
    fields=['title', 'status'],
    extra=0,          # 不显示额外空行
    can_delete=True,  # 允许删除
)

# 在视图中使用
def bulk_edit(request):
    if request.method == 'POST':
        formset = ArticleModelFormSet(request.POST)
        if formset.is_valid():
            formset.save()
            return redirect('blog:article_list')
    else:
        formset = ArticleModelFormSet(
            queryset=Article.objects.filter(author=request.user)
        )
    return render(request, 'blog/bulk_edit.html', {'formset': formset})

模板中使用表单集

<form method="post">
    {% csrf_token %}
    {{ formset.management_form }}

    {% for form in formset %}
    <div class="form-row">
        {{ form.id }}
        {{ form.title }}
        {{ form.status }}
        {% if form.DELETE %}{{ form.DELETE }}{% endif %}
    </div>
    {% endfor %}

    <button type="submit">保存全部</button>
</form>

七、文件上传处理

# settings.py
MEDIA_URL = '/media/'
MEDIA_ROOT = BASE_DIR / 'media'

# forms.py
class AvatarUploadForm(forms.ModelForm):
    class Meta:
        model = Profile
        fields = ['avatar']

    def clean_avatar(self):
        avatar = self.cleaned_data.get('avatar')
        # 验证文件类型
        if avatar:
            if avatar.content_type not in ['image/jpeg', 'image/png', 'image/gif']:
                raise forms.ValidationError('只支持 JPG、PNG、GIF 格式')
            # 验证文件大小(2MB)
            if avatar.size > 2 * 1024 * 1024:
                raise forms.ValidationError('图片大小不能超过 2MB')
            # 验证图片尺寸
            from PIL import Image
            img = Image.open(avatar)
            if img.width < 200 or img.height < 200:
                raise forms.ValidationError('图片尺寸不能小于 200x200')
        return avatar

# views.py
def upload_avatar(request):
    if request.method == 'POST':
        form = AvatarUploadForm(request.POST, request.FILES, instance=request.user.profile)
        if form.is_valid():
            form.save()
            return redirect('profile')
    else:
        form = AvatarUploadForm(instance=request.user.profile)
    return render(request, 'upload.html', {'form': form})

八、CSRF防护

Django默认开启CSRF防护,POST表单必须包含CSRF令牌:

<!-- 方式1:模板标签 -->
<form method="post">
    {% csrf_token %}
    ...
</form>

<!-- 方式2:AJAX请求 -->
<script>
// 从cookie中获取CSRF令牌
function getCookie(name) {
    let cookieValue = null;
    if (document.cookie && document.cookie !== '') {
        const cookies = document.cookie.split(';');
        for (let i = 0; i < cookies.length; i++) {
            const cookie = cookies[i].trim();
            if (cookie.substring(0, name.length + 1) === (name + '=')) {
                cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
                break;
            }
        }
    }
    return cookieValue;
}

const csrftoken = getCookie('csrftoken');

fetch('/api/articles/', {
    method: 'POST',
    headers: {
        'Content-Type': 'application/json',
        'X-CSRFToken': csrftoken,
    },
    body: JSON.stringify({title: '新文章', content: '内容'}),
});
</script>

九、总结

本章我们学习了Django表单系统的核心知识:

  • Form类和ModelForm的定义与使用
  • 表单渲染的三种方式
  • 字段类型、内置验证器和自定义验证
  • 表单集(Formset)批量处理
  • 文件上传处理和安全验证
  • CSRF防护机制

下一章我们将学习 Django用户认证与权限系统,实现注册、登录、权限控制等核心功能。

发表回复

后才能评论