Django 表单处理
2026/3/20大约 13 分钟
Django 表单处理
一、表单基础
1.1 表单类概述
Django 表单系统提供了强大的数据验证和处理功能,将 HTML 表单生成、数据验证、数据清洗等功能封装在一起。
from django import forms
class ContactForm(forms.Form):
"""联系表单"""
name = forms.CharField(
label='姓名',
max_length=100,
widget=forms.TextInput(attrs={'class': 'form-control'})
)
email = forms.EmailField(
label='邮箱',
widget=forms.EmailInput(attrs={'class': 'form-control'})
)
subject = forms.CharField(
label='主题',
max_length=200
)
message = forms.CharField(
label='留言',
widget=forms.Textarea(attrs={'rows': 5})
)
1.2 表单字段类型
from django import forms
from django.core.validators import MinLengthValidator
class FieldExampleForm(forms.Form):
# 文本字段
char_field = forms.CharField(max_length=100)
text_field = forms.CharField(widget=forms.Textarea)
slug_field = forms.SlugField()
# 数字字段
integer_field = forms.IntegerField(min_value=0, max_value=100)
float_field = forms.FloatField()
decimal_field = forms.DecimalField(max_digits=10, decimal_places=2)
# 日期时间字段
date_field = forms.DateField(widget=forms.DateInput(attrs={'type': 'date'}))
time_field = forms.TimeField(widget=forms.TimeInput(attrs={'type': 'time'}))
datetime_field = forms.DateTimeField()
duration_field = forms.DurationField()
# 选择字段
choice_field = forms.ChoiceField(choices=[
('', '请选择'),
('option1', '选项1'),
('option2', '选项2'),
])
multiple_choice = forms.MultipleChoiceField(
choices=[('a', 'A'), ('b', 'B'), ('c', 'C')],
widget=forms.CheckboxSelectMultiple
)
typed_choice = forms.TypedChoiceField(
choices=[(1, '是'), (0, '否')],
coerce=int # 转换为整数
)
# 布尔字段
boolean_field = forms.BooleanField(required=False)
null_boolean = forms.NullBooleanField()
# 文件字段
file_field = forms.FileField()
image_field = forms.ImageField()
# 其他字段
email_field = forms.EmailField()
url_field = forms.URLField()
ip_field = forms.GenericIPAddressField()
uuid_field = forms.UUIDField()
regex_field = forms.RegexField(regex=r'^\d{6}$') # 6位数字
# JSON 字段
json_field = forms.JSONField()
1.3 字段参数
class FormFieldOptionsForm(forms.Form):
username = forms.CharField(
required=True, # 是否必填,默认 True
label='用户名', # 标签文本
label_suffix=':', # 标签后缀
initial='默认值', # 初始值
help_text='请输入用户名', # 帮助文本
error_messages={ # 自定义错误消息
'required': '用户名不能为空',
'max_length': '用户名太长了',
},
validators=[MinLengthValidator(3)], # 验证器
disabled=False, # 是否禁用
widget=forms.TextInput( # 自定义 widget
attrs={
'class': 'form-control',
'placeholder': '请输入用户名',
'autocomplete': 'username',
}
),
)
# 字段分组(用于模板渲染)
password = forms.CharField(
widget=forms.PasswordInput(render_value=False)
)
二、Widget 详解
2.1 常用 Widget
from django import forms
class WidgetExamplesForm(forms.Form):
# 文本输入
text = forms.CharField(widget=forms.TextInput)
password = forms.CharField(widget=forms.PasswordInput)
hidden = forms.CharField(widget=forms.HiddenInput)
textarea = forms.CharField(widget=forms.Textarea)
# 数字输入
number = forms.IntegerField(widget=forms.NumberInput)
# 日期时间
date = forms.DateField(widget=forms.DateInput(attrs={'type': 'date'}))
time = forms.TimeField(widget=forms.TimeInput(attrs={'type': 'time'}))
datetime = forms.DateTimeField(widget=forms.DateTimeInput)
# 选择
select = forms.ChoiceField(
choices=[('a', 'A'), ('b', 'B')],
widget=forms.Select
)
select_multiple = forms.MultipleChoiceField(
choices=[('a', 'A'), ('b', 'B'), ('c', 'C')],
widget=forms.SelectMultiple
)
radio = forms.ChoiceField(
choices=[('a', 'A'), ('b', 'B')],
widget=forms.RadioSelect
)
checkbox = forms.BooleanField(widget=forms.CheckboxInput)
checkbox_multiple = forms.MultipleChoiceField(
choices=[('a', 'A'), ('b', 'B')],
widget=forms.CheckboxSelectMultiple
)
# 文件
file = forms.FileField(widget=forms.FileInput)
clearable_file = forms.FileField(widget=forms.ClearableFileInput)
# 邮箱和 URL
email = forms.EmailField(widget=forms.EmailInput)
url = forms.URLField(widget=forms.URLInput)
2.2 自定义 Widget 属性
class CustomWidgetForm(forms.Form):
username = forms.CharField(
widget=forms.TextInput(attrs={
'class': 'form-control',
'id': 'username-input',
'placeholder': '请输入用户名',
'data-validate': 'true',
'autofocus': True,
})
)
content = forms.CharField(
widget=forms.Textarea(attrs={
'class': 'form-control',
'rows': 10,
'cols': 80,
})
)
category = forms.ChoiceField(
choices=[('tech', '技术'), ('life', '生活')],
widget=forms.Select(attrs={
'class': 'form-select',
'onchange': 'handleChange()',
})
)
2.3 自定义 Widget
from django.forms.widgets import Widget
from django.utils.html import format_html
class StarRatingWidget(Widget):
"""星级评分 Widget"""
template_name = 'widgets/star_rating.html'
def __init__(self, max_stars=5, attrs=None):
self.max_stars = max_stars
super().__init__(attrs)
def get_context(self, name, value, attrs):
context = super().get_context(name, value, attrs)
context['widget']['max_stars'] = self.max_stars
return context
class ColorPickerWidget(forms.TextInput):
"""颜色选择器"""
input_type = 'color'
def __init__(self, attrs=None):
default_attrs = {'class': 'color-picker'}
if attrs:
default_attrs.update(attrs)
super().__init__(default_attrs)
# widgets/star_rating.html
"""
{% for i in widget.max_stars|make_list %}
<input type="radio" name="{{ widget.name }}" value="{{ forloop.counter }}"
{% if widget.value == forloop.counter|stringformat:"s" %}checked{% endif %}>
<label>⭐</label>
{% endfor %}
"""
三、表单验证
3.1 字段级验证
from django import forms
from django.core.exceptions import ValidationError
import re
class RegistrationForm(forms.Form):
username = forms.CharField(max_length=50)
email = forms.EmailField()
password = forms.CharField(widget=forms.PasswordInput)
password_confirm = forms.CharField(widget=forms.PasswordInput)
phone = forms.CharField(max_length=11)
def clean_username(self):
"""验证用户名"""
username = self.cleaned_data.get('username')
# 检查是否只包含字母、数字和下划线
if not re.match(r'^[a-zA-Z0-9_]+$', username):
raise ValidationError('用户名只能包含字母、数字和下划线')
# 检查是否已存在
from users.models import User
if User.objects.filter(username=username).exists():
raise ValidationError('该用户名已被使用')
return username.lower() # 返回处理后的值
def clean_phone(self):
"""验证手机号"""
phone = self.cleaned_data.get('phone')
if not re.match(r'^1[3-9]\d{9}$', phone):
raise ValidationError('请输入有效的手机号')
return phone
def clean_email(self):
"""验证邮箱"""
email = self.cleaned_data.get('email')
# 禁止某些邮箱域名
forbidden_domains = ['tempmail.com', 'throwaway.com']
domain = email.split('@')[1]
if domain in forbidden_domains:
raise ValidationError('不支持该邮箱域名')
return email.lower()
3.2 表单级验证
class RegistrationForm(forms.Form):
# ... 字段定义 ...
def clean(self):
"""跨字段验证"""
cleaned_data = super().clean()
password = cleaned_data.get('password')
password_confirm = cleaned_data.get('password_confirm')
if password and password_confirm:
if password != password_confirm:
raise ValidationError('两次输入的密码不一致')
# 密码强度检查
if len(password) < 8:
self.add_error('password', '密码至少需要8位')
if not re.search(r'[A-Z]', password):
self.add_error('password', '密码需要包含大写字母')
if not re.search(r'[0-9]', password):
self.add_error('password', '密码需要包含数字')
return cleaned_data
3.3 自定义验证器
from django.core.validators import BaseValidator
from django.core.exceptions import ValidationError
import re
# 函数验证器
def validate_no_special_chars(value):
"""禁止特殊字符"""
if re.search(r'[<>{}]', value):
raise ValidationError('不允许使用特殊字符:<>{}')
def validate_image_size(image):
"""验证图片大小"""
max_size = 5 * 1024 * 1024 # 5MB
if image.size > max_size:
raise ValidationError(f'图片大小不能超过 {max_size // 1024 // 1024}MB')
# 类验证器
class MinAgeValidator(BaseValidator):
"""最小年龄验证器"""
message = '年龄必须至少为 %(limit_value)s 岁'
code = 'min_age'
def compare(self, value, limit):
from datetime import date
today = date.today()
age = today.year - value.year - ((today.month, today.day) < (value.month, value.day))
return age < limit
class FileSizeValidator:
"""文件大小验证器"""
def __init__(self, max_size_mb):
self.max_size = max_size_mb * 1024 * 1024
def __call__(self, file):
if file.size > self.max_size:
raise ValidationError(
f'文件大小不能超过 {self.max_size // 1024 // 1024}MB'
)
# 使用验证器
class ProfileForm(forms.Form):
bio = forms.CharField(validators=[validate_no_special_chars])
avatar = forms.ImageField(validators=[validate_image_size])
birthday = forms.DateField(validators=[MinAgeValidator(18)])
resume = forms.FileField(validators=[FileSizeValidator(10)])
四、ModelForm
4.1 基础 ModelForm
from django import forms
from .models import Article, Category
class ArticleForm(forms.ModelForm):
class Meta:
model = Article
fields = ['title', 'content', 'category', 'tags', 'status']
# 或者排除某些字段
# exclude = ['author', 'created_at', 'updated_at']
# 自定义标签
labels = {
'title': '标题',
'content': '内容',
'category': '分类',
}
# 帮助文本
help_texts = {
'title': '请输入文章标题,不超过200字',
'tags': '可以选择多个标签',
}
# 错误消息
error_messages = {
'title': {
'required': '标题不能为空',
'max_length': '标题太长了',
},
}
# 自定义 Widget
widgets = {
'title': forms.TextInput(attrs={'class': 'form-control'}),
'content': forms.Textarea(attrs={'class': 'form-control', 'rows': 10}),
'category': forms.Select(attrs={'class': 'form-select'}),
'tags': forms.CheckboxSelectMultiple(),
'status': forms.RadioSelect(),
}
# 字段类型覆盖
field_classes = {
'slug': forms.SlugField,
}
4.2 自定义 ModelForm
class ArticleForm(forms.ModelForm):
# 添加额外字段
notify_subscribers = forms.BooleanField(
label='通知订阅者',
required=False,
initial=True,
)
# 覆盖模型字段
category = forms.ModelChoiceField(
queryset=Category.objects.filter(is_active=True),
empty_label='请选择分类',
widget=forms.Select(attrs={'class': 'form-select'})
)
class Meta:
model = Article
fields = ['title', 'content', 'category', 'tags']
def __init__(self, *args, **kwargs):
# 获取额外参数
self.user = kwargs.pop('user', None)
super().__init__(*args, **kwargs)
# 动态修改字段
if self.user and not self.user.is_staff:
self.fields['status'].choices = [
('draft', '草稿'),
('pending', '待审核'),
]
# 设置字段属性
for field_name, field in self.fields.items():
if not isinstance(field.widget, forms.CheckboxInput):
field.widget.attrs['class'] = 'form-control'
def clean_title(self):
title = self.cleaned_data.get('title')
# 检查标题是否重复(排除当前实例)
qs = Article.objects.filter(title=title)
if self.instance.pk:
qs = qs.exclude(pk=self.instance.pk)
if qs.exists():
raise ValidationError('该标题已存在')
return title
def save(self, commit=True):
instance = super().save(commit=False)
# 设置作者
if not instance.pk and self.user:
instance.author = self.user
if commit:
instance.save()
self.save_m2m() # 保存多对多关系
# 处理额外逻辑
if self.cleaned_data.get('notify_subscribers'):
self.notify_subscribers_func(instance)
return instance
def notify_subscribers_func(self, article):
# 通知订阅者的逻辑
pass
4.3 关联字段处理
class ArticleForm(forms.ModelForm):
class Meta:
model = Article
fields = ['title', 'content', 'category', 'tags']
# 外键字段
category = forms.ModelChoiceField(
queryset=Category.objects.all(),
to_field_name='slug', # 使用 slug 而不是 pk
empty_label='请选择',
)
# 多对多字段
tags = forms.ModelMultipleChoiceField(
queryset=Tag.objects.all(),
widget=forms.CheckboxSelectMultiple,
required=False,
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# 限制查询集
self.fields['category'].queryset = Category.objects.filter(
is_active=True
).order_by('name')
# 分组选择
self.fields['category'].choices = self.get_grouped_categories()
def get_grouped_categories(self):
"""返回分组的分类选项"""
choices = [('', '请选择')]
parents = Category.objects.filter(parent__isnull=True)
for parent in parents:
group = [(child.pk, child.name) for child in parent.children.all()]
if group:
choices.append((parent.name, group))
else:
choices.append((parent.pk, parent.name))
return choices
五、表单集(Formset)
5.1 基础 Formset
from django.forms import formset_factory
class ItemForm(forms.Form):
name = forms.CharField(max_length=100)
quantity = forms.IntegerField(min_value=1)
price = forms.DecimalField(max_digits=10, decimal_places=2)
# 创建 Formset
ItemFormSet = formset_factory(
ItemForm,
extra=3, # 额外的空表单数量
max_num=10, # 最大表单数量
min_num=1, # 最小表单数量
validate_min=True, # 验证最小数量
validate_max=True, # 验证最大数量
can_order=True, # 允许排序
can_delete=True, # 允许删除
)
# 视图中使用
def order_items(request):
if request.method == 'POST':
formset = ItemFormSet(request.POST)
if formset.is_valid():
for form in formset:
if form.cleaned_data and not form.cleaned_data.get('DELETE'):
# 处理数据
name = form.cleaned_data['name']
quantity = form.cleaned_data['quantity']
# ...
return redirect('success')
else:
formset = ItemFormSet()
return render(request, 'order.html', {'formset': formset})
5.2 Model Formset
from django.forms import modelformset_factory
class BookForm(forms.ModelForm):
class Meta:
model = Book
fields = ['title', 'author', 'price']
BookFormSet = modelformset_factory(
Book,
form=BookForm,
extra=2,
can_delete=True,
)
# 带查询集
def edit_books(request):
queryset = Book.objects.filter(author=request.user)
if request.method == 'POST':
formset = BookFormSet(request.POST, queryset=queryset)
if formset.is_valid():
formset.save()
return redirect('book_list')
else:
formset = BookFormSet(queryset=queryset)
return render(request, 'edit_books.html', {'formset': formset})
5.3 内联 Formset
from django.forms import inlineformset_factory
class Author(models.Model):
name = models.CharField(max_length=100)
class Book(models.Model):
author = models.ForeignKey(Author, on_delete=models.CASCADE)
title = models.CharField(max_length=200)
# 创建内联 Formset
BookFormSet = inlineformset_factory(
Author, # 父模型
Book, # 子模型
fields=['title'],
extra=3,
can_delete=True,
)
# 视图
def edit_author_books(request, author_id):
author = get_object_or_404(Author, pk=author_id)
if request.method == 'POST':
formset = BookFormSet(request.POST, instance=author)
if formset.is_valid():
formset.save()
return redirect('author_detail', author_id=author.id)
else:
formset = BookFormSet(instance=author)
return render(request, 'edit_books.html', {
'author': author,
'formset': formset,
})
5.4 Formset 验证
from django.forms import BaseFormSet
class BaseItemFormSet(BaseFormSet):
def clean(self):
"""Formset 级别的验证"""
if any(self.errors):
return
names = []
total_quantity = 0
for form in self.forms:
if form.cleaned_data and not form.cleaned_data.get('DELETE', False):
name = form.cleaned_data.get('name')
quantity = form.cleaned_data.get('quantity', 0)
# 检查重复
if name in names:
raise ValidationError('商品名称不能重复')
names.append(name)
total_quantity += quantity
# 检查总数量
if total_quantity > 100:
raise ValidationError('总数量不能超过100')
# 至少需要一个有效表单
if not names:
raise ValidationError('至少需要添加一个商品')
ItemFormSet = formset_factory(
ItemForm,
formset=BaseItemFormSet,
extra=3,
)
六、文件上传
6.1 基础文件上传
class UploadForm(forms.Form):
title = forms.CharField(max_length=100)
file = forms.FileField()
def clean_file(self):
file = self.cleaned_data.get('file')
if file:
# 检查文件大小
if file.size > 10 * 1024 * 1024: # 10MB
raise ValidationError('文件大小不能超过10MB')
# 检查文件类型
allowed_types = ['application/pdf', 'image/jpeg', 'image/png']
if file.content_type not in allowed_types:
raise ValidationError('只允许上传 PDF 和图片文件')
# 检查文件扩展名
ext = file.name.split('.')[-1].lower()
if ext not in ['pdf', 'jpg', 'jpeg', 'png']:
raise ValidationError('不支持的文件格式')
return file
# 视图
def upload_file(request):
if request.method == 'POST':
form = UploadForm(request.POST, request.FILES)
if form.is_valid():
# 处理文件
uploaded_file = request.FILES['file']
# 保存到模型
document = Document.objects.create(
title=form.cleaned_data['title'],
file=uploaded_file
)
return redirect('document_detail', pk=document.pk)
else:
form = UploadForm()
return render(request, 'upload.html', {'form': form})
6.2 图片上传
from PIL import Image
from io import BytesIO
from django.core.files.uploadedfile import InMemoryUploadedFile
class AvatarForm(forms.ModelForm):
class Meta:
model = UserProfile
fields = ['avatar']
def clean_avatar(self):
avatar = self.cleaned_data.get('avatar')
if avatar:
# 验证是否为图片
try:
img = Image.open(avatar)
img.verify()
except Exception:
raise ValidationError('请上传有效的图片文件')
# 重新打开进行处理
avatar.seek(0)
img = Image.open(avatar)
# 检查尺寸
if img.width > 4000 or img.height > 4000:
raise ValidationError('图片尺寸不能超过 4000x4000')
# 压缩和调整大小
avatar = self.process_image(img, avatar.name)
return avatar
def process_image(self, img, name):
"""处理图片:调整大小、压缩"""
# 转换为 RGB
if img.mode in ('RGBA', 'P'):
img = img.convert('RGB')
# 调整大小
max_size = (800, 800)
img.thumbnail(max_size, Image.Resampling.LANCZOS)
# 保存到内存
output = BytesIO()
img.save(output, format='JPEG', quality=85)
output.seek(0)
return InMemoryUploadedFile(
output,
'ImageField',
f"{name.split('.')[0]}.jpg",
'image/jpeg',
output.getbuffer().nbytes,
None
)
6.3 多文件上传
class MultipleFileInput(forms.ClearableFileInput):
allow_multiple_selected = True
class MultipleFileField(forms.FileField):
def __init__(self, *args, **kwargs):
kwargs.setdefault('widget', MultipleFileInput())
super().__init__(*args, **kwargs)
def clean(self, data, initial=None):
single_file_clean = super().clean
if isinstance(data, (list, tuple)):
return [single_file_clean(d, initial) for d in data]
return single_file_clean(data, initial)
class GalleryForm(forms.Form):
images = MultipleFileField()
def clean_images(self):
images = self.cleaned_data.get('images')
if images:
if len(images) > 10:
raise ValidationError('最多只能上传10张图片')
for image in images:
if image.size > 5 * 1024 * 1024:
raise ValidationError(f'{image.name} 超过5MB限制')
return images
# 视图
def upload_gallery(request):
if request.method == 'POST':
form = GalleryForm(request.POST, request.FILES)
if form.is_valid():
images = request.FILES.getlist('images')
for image in images:
Photo.objects.create(image=image)
return redirect('gallery')
else:
form = GalleryForm()
return render(request, 'upload_gallery.html', {'form': form})
七、视图中使用表单
7.1 函数视图
from django.shortcuts import render, redirect, get_object_or_404
from django.contrib import messages
from .forms import ArticleForm
from .models import Article
def article_create(request):
if request.method == 'POST':
form = ArticleForm(request.POST, request.FILES)
if form.is_valid():
article = form.save(commit=False)
article.author = request.user
article.save()
form.save_m2m() # 保存多对多关系
messages.success(request, '文章创建成功!')
return redirect('article_detail', pk=article.pk)
else:
messages.error(request, '请检查表单错误')
else:
form = ArticleForm()
return render(request, 'blog/article_form.html', {'form': form})
def article_update(request, pk):
article = get_object_or_404(Article, pk=pk, author=request.user)
if request.method == 'POST':
form = ArticleForm(request.POST, request.FILES, instance=article)
if form.is_valid():
form.save()
messages.success(request, '文章更新成功!')
return redirect('article_detail', pk=article.pk)
else:
form = ArticleForm(instance=article)
return render(request, 'blog/article_form.html', {
'form': form,
'article': article,
})
7.2 类视图
from django.views.generic import CreateView, UpdateView
from django.contrib.auth.mixins import LoginRequiredMixin
from django.urls import reverse_lazy
from .forms import ArticleForm
from .models import Article
class ArticleCreateView(LoginRequiredMixin, CreateView):
model = Article
form_class = ArticleForm
template_name = 'blog/article_form.html'
success_url = reverse_lazy('article_list')
def get_form_kwargs(self):
"""传递额外参数给表单"""
kwargs = super().get_form_kwargs()
kwargs['user'] = self.request.user
return kwargs
def form_valid(self, form):
form.instance.author = self.request.user
messages.success(self.request, '文章创建成功!')
return super().form_valid(form)
def form_invalid(self, form):
messages.error(self.request, '请检查表单错误')
return super().form_invalid(form)
def get_success_url(self):
return reverse('article_detail', kwargs={'pk': self.object.pk})
class ArticleUpdateView(LoginRequiredMixin, UpdateView):
model = Article
form_class = ArticleForm
template_name = 'blog/article_form.html'
def get_queryset(self):
return super().get_queryset().filter(author=self.request.user)
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['is_update'] = True
return context
7.3 AJAX 表单处理
from django.http import JsonResponse
from django.views import View
import json
class ArticleAPIView(View):
def post(self, request):
form = ArticleForm(request.POST, request.FILES)
if form.is_valid():
article = form.save(commit=False)
article.author = request.user
article.save()
form.save_m2m()
return JsonResponse({
'success': True,
'message': '文章创建成功',
'data': {
'id': article.id,
'title': article.title,
'url': article.get_absolute_url(),
}
})
return JsonResponse({
'success': False,
'errors': form.errors,
}, status=400)
# JavaScript
"""
fetch('/api/articles/', {
method: 'POST',
body: new FormData(document.querySelector('form')),
headers: {
'X-CSRFToken': getCookie('csrftoken'),
}
})
.then(response => response.json())
.then(data => {
if (data.success) {
window.location.href = data.data.url;
} else {
// 显示错误
Object.entries(data.errors).forEach(([field, errors]) => {
const input = document.querySelector(`[name="${field}"]`);
input.classList.add('is-invalid');
input.nextElementSibling.textContent = errors.join(', ');
});
}
});
"""
八、表单渲染
8.1 自动渲染
<form method="post" enctype="multipart/form-data">
{% csrf_token %}
<!-- 完整表单 -->
{{ form.as_p }} {{ form.as_table }} {{ form.as_ul }} {{ form.as_div }}
<!-- Django 4.0+ -->
<button type="submit">提交</button>
</form>
8.2 手动渲染
<form method="post" enctype="multipart/form-data">
{% csrf_token %}
<!-- 非字段错误 -->
{% if form.non_field_errors %}
<div class="alert alert-danger">
{% for error in form.non_field_errors %}
<p>{{ error }}</p>
{% endfor %}
</div>
{% endif %}
<!-- 单个字段 -->
<div class="mb-3">
<label for="{{ form.title.id_for_label }}" class="form-label">
{{ form.title.label }} {% if form.title.field.required %}<span
class="text-danger"
>*</span
>{% endif %}
</label>
{{ form.title }} {% if form.title.help_text %}
<div class="form-text">{{ form.title.help_text }}</div>
{% endif %} {% if form.title.errors %}
<div class="invalid-feedback d-block">
{% for error in form.title.errors %} {{ error }} {% endfor %}
</div>
{% endif %}
</div>
<!-- 隐藏字段 -->
{% for hidden in form.hidden_fields %} {{ hidden }} {% endfor %}
<button type="submit" class="btn btn-primary">提交</button>
</form>
8.3 循环渲染
<form method="post" enctype="multipart/form-data">
{% csrf_token %} {% for field in form %}
<div class="mb-3 {% if field.errors %}has-error{% endif %}">
{% if field.is_hidden %} {{ field }} {% else %}
<label for="{{ field.id_for_label }}" class="form-label">
{{ field.label }} {% if field.field.required %}<span class="text-danger"
>*</span
>{% endif %}
</label>
{% if field.field.widget.input_type == 'checkbox' %}
<div class="form-check">
{{ field }}
<label class="form-check-label" for="{{ field.id_for_label }}">
{{ field.label }}
</label>
</div>
{% else %} {{ field }} {% endif %} {% if field.help_text %}
<small class="form-text text-muted">{{ field.help_text }}</small>
{% endif %} {% for error in field.errors %}
<div class="invalid-feedback d-block">{{ error }}</div>
{% endfor %} {% endif %}
</div>
{% endfor %}
<button type="submit" class="btn btn-primary">提交</button>
</form>
8.4 使用 crispy-forms
# 安装
# pip install django-crispy-forms crispy-bootstrap5
# settings.py
INSTALLED_APPS = [
...
'crispy_forms',
'crispy_bootstrap5',
]
CRISPY_ALLOWED_TEMPLATE_PACKS = "bootstrap5"
CRISPY_TEMPLATE_PACK = "bootstrap5"
from crispy_forms.helper import FormHelper
from crispy_forms.layout import Layout, Fieldset, Submit, Row, Column, HTML
class ArticleForm(forms.ModelForm):
class Meta:
model = Article
fields = ['title', 'content', 'category', 'tags']
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.helper = FormHelper()
self.helper.form_method = 'post'
self.helper.form_class = 'form-horizontal'
self.helper.label_class = 'col-lg-2'
self.helper.field_class = 'col-lg-10'
self.helper.layout = Layout(
Fieldset(
'文章信息',
'title',
'category',
Row(
Column('status', css_class='col-md-6'),
Column('is_featured', css_class='col-md-6'),
),
),
Fieldset(
'文章内容',
'content',
'tags',
),
HTML('<hr>'),
Submit('submit', '保存', css_class='btn btn-primary'),
)
{% load crispy_forms_tags %}
<!-- 完整表单 -->
{% crispy form %}
<!-- 或者只应用样式 -->
<form method="post">
{% csrf_token %} {{ form|crispy }}
<button type="submit">提交</button>
</form>
九、高级技巧
9.1 动态表单字段
class DynamicForm(forms.Form):
def __init__(self, *args, **kwargs):
fields_config = kwargs.pop('fields_config', [])
super().__init__(*args, **kwargs)
for config in fields_config:
field_type = config.get('type', 'text')
field_name = config['name']
field_label = config.get('label', field_name)
if field_type == 'text':
self.fields[field_name] = forms.CharField(label=field_label)
elif field_type == 'number':
self.fields[field_name] = forms.IntegerField(label=field_label)
elif field_type == 'email':
self.fields[field_name] = forms.EmailField(label=field_label)
elif field_type == 'choice':
self.fields[field_name] = forms.ChoiceField(
label=field_label,
choices=config.get('choices', [])
)
# 使用
fields_config = [
{'name': 'name', 'type': 'text', 'label': '姓名'},
{'name': 'age', 'type': 'number', 'label': '年龄'},
{'name': 'gender', 'type': 'choice', 'label': '性别',
'choices': [('M', '男'), ('F', '女')]},
]
form = DynamicForm(fields_config=fields_config)
9.2 表单向导
from django.contrib.formtools.wizard.views import SessionWizardView
class RegistrationStep1Form(forms.Form):
email = forms.EmailField()
username = forms.CharField(max_length=50)
class RegistrationStep2Form(forms.Form):
first_name = forms.CharField(max_length=50)
last_name = forms.CharField(max_length=50)
class RegistrationStep3Form(forms.Form):
password = forms.CharField(widget=forms.PasswordInput)
password_confirm = forms.CharField(widget=forms.PasswordInput)
class RegistrationWizard(SessionWizardView):
form_list = [RegistrationStep1Form, RegistrationStep2Form, RegistrationStep3Form]
template_name = 'registration/wizard.html'
def done(self, form_list, **kwargs):
data = {}
for form in form_list:
data.update(form.cleaned_data)
# 创建用户
user = User.objects.create_user(
username=data['username'],
email=data['email'],
password=data['password'],
first_name=data['first_name'],
last_name=data['last_name'],
)
return redirect('registration_complete')
9.3 表单混入类
class TimestampFormMixin:
"""添加时间戳相关逻辑"""
def save(self, commit=True):
instance = super().save(commit=False)
if not instance.pk:
instance.created_at = timezone.now()
instance.updated_at = timezone.now()
if commit:
instance.save()
return instance
class SlugFormMixin:
"""自动生成 slug"""
def save(self, commit=True):
instance = super().save(commit=False)
if not instance.slug:
from django.utils.text import slugify
instance.slug = slugify(instance.title)
if commit:
instance.save()
return instance
class ArticleForm(TimestampFormMixin, SlugFormMixin, forms.ModelForm):
class Meta:
model = Article
fields = ['title', 'content']
9.4 表单继承
class BaseArticleForm(forms.ModelForm):
class Meta:
model = Article
fields = ['title', 'content']
def clean_title(self):
title = self.cleaned_data.get('title')
if len(title) < 5:
raise ValidationError('标题至少需要5个字符')
return title
class PublishArticleForm(BaseArticleForm):
"""发布文章表单,增加更多字段"""
class Meta(BaseArticleForm.Meta):
fields = BaseArticleForm.Meta.fields + ['category', 'tags', 'cover_image']
def clean(self):
cleaned_data = super().clean()
if not cleaned_data.get('category'):
raise ValidationError('发布文章必须选择分类')
return cleaned_data
class DraftArticleForm(BaseArticleForm):
"""草稿表单,字段非必填"""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
for field in self.fields.values():
field.required = False
十、最佳实践
10.1 表单组织
myapp/
├── forms/
│ ├── __init__.py
│ ├── article.py # 文章相关表单
│ ├── user.py # 用户相关表单
│ ├── mixins.py # 表单混入类
│ └── widgets.py # 自定义 Widget
├── validators.py # 验证器
└── ...
10.2 安全注意事项
class SecureForm(forms.Form):
# 1. 始终使用 CSRF 保护
# 模板中:{% csrf_token %}
# 2. 验证文件类型
file = forms.FileField()
def clean_file(self):
file = self.cleaned_data.get('file')
# 不要只检查扩展名,要检查内容
import magic
mime = magic.from_buffer(file.read(1024), mime=True)
file.seek(0)
if mime not in ['image/jpeg', 'image/png']:
raise ValidationError('只允许 JPEG 和 PNG 图片')
return file
# 3. 清理 HTML 内容
content = forms.CharField()
def clean_content(self):
content = self.cleaned_data.get('content')
import bleach
return bleach.clean(content, tags=['p', 'a', 'b', 'i'], strip=True)
# 4. 限制输入长度
username = forms.CharField(max_length=50)
# 5. 使用 TypedChoiceField 确保类型
status = forms.TypedChoiceField(
choices=[(1, '启用'), (0, '禁用')],
coerce=int
)
10.3 性能优化
class OptimizedForm(forms.ModelForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# 优化外键查询
self.fields['category'].queryset = Category.objects.only('id', 'name')
# 缓存选择项
self.fields['tags'].queryset = Tag.objects.all().select_related('parent')
# 避免在验证中进行重复查询
def clean(self):
cleaned_data = super().clean()
# 批量验证而不是多次查询
return cleaned_data