Django+HTMX联动下拉框修改QuerySet提交报错原因排查
Django+HTMX联动下拉框提交验证失败问题
问题概述
使用Django结合HTMX实现省份-城市联动下拉框,因数据集较大,希望通过HTMX按需加载城市数据,而非初始加载全部。当表单字段queryset设为Model.objects.all()时,表单提交正常;但改为Model.objects.none()或Model.objects.all()[:xx](无/少量初始数据)后,联动功能可正常触发加载城市,但提交表单时会出现错误提示:
Select a valid Choice. That choice is not one of the available choices.
相关代码
models.py
class Province(models.Model): province_code = models.CharField(max_length=2) province_name = models.CharField(max_length=30) class City(models.Model): province = models.ForeignKey(Province,on_delete=models.CASCADE) city_code = models.CharField(max_length=2) city_name = models.CharField(max_length=30) class Address(models.Model): province = models.ForeignKey(Province,on_delete=models.CASCADE) city = models.ForeignKey(City, on_delete=models.CASCADE) address = models.CharField(max_length=50)
forms.py
class AddressForm(ModelForm): class Meta: model = Addreess # 注:此处存在笔误,应为 Address fields = '__all__' widgets = { "province": form.Select( attrs={ "hx-get":"import/load-city/", "hx-target":"#city", "hx-trigger":"change", "hx-swap":"innerHTML", } ), "city": form.Select( attrs={ "id":"city" } ), } def __init__(self, *args, **kwargs): super(AddressForm, self).__init__(*args,**kwargs) self.fields["province"].queryset = Province.objects.all() self.fields["city"].queryset = City.objects.all() ## 注释:改为 .none() 或 .all()[:xx] 时提交报错
views.py
class AddressCreateView(CreateView): model = Address form_class = AddressForm template_name = 'address_create.html' success_url = '/' class LoadCityView(View): def get(self,request): province = request.GET.get("province") cities = City.objects.filter(province=province) if province else City.objects.none() context = {"cities":cities} return render(request,"load_cities.html",context)
urls.py
urlpatterns = [ path('address-create/', AddressCreateView.as_view(), name='address-create'), path('load-city/', LoadCityView.as_view(), name='load-city'), ]
模板文件
address_create.html
<!DOCTYPE html> <html> <head> </head> <body> <form method="post"> {% csrf_token %} {{ form.as_p }} <input type="submit"> </form> <script src="https://ajax.googleapis.com/ajax/libs/jquery/3.7.1/jquery.min.js"></script> <script src="https://unpkg.com/htmx.org@1.6.1"></script> <script> document.body.addEventListener('htmx:configRequest', (event) => { event.detail.headers['X-CSRFToken'] = '{{csrf_token}}'; }) </script> </body> </html>
load_cities.html
<option value="">------------</option> {% for city in cities %} <option value="{{city.id}}">{{city.city_code}} {{city.city_name}}</option> {% endfor %}
问题原因
1. QuerySet的本质差异
Model.objects.all()返回惰性求值的QuerySet:它仅定义查询逻辑,未立即执行数据库查询,在表单验证阶段会重新执行查询,获取最新的全量可选值。Model.objects.none()返回空QuerySet,Model.objects.all()[:xx]返回已切片的QuerySet:这两种QuerySet在表单初始化时就固定了可选范围(空或仅初始少量数据),验证阶段不会重新查询数据库更新可选值。
2. Django表单验证逻辑
Django的ModelChoiceField(外键字段默认使用该类型)在验证时,会严格检查提交的选项值是否存在于字段当前的queryset中。HTMX动态加载的城市选项仅渲染到页面下拉框,但并未添加到表单字段的queryset里,因此当表单初始化时city的queryset为空或仅少量数据时,提交的城市ID会被判定为不在可选范围内,从而抛出验证错误。
解决方法
方法1:重写表单的clean方法,直接验证选项有效性
绕过queryset限制,直接查询数据库验证提交的城市ID是否真实存在:
class AddressForm(ModelForm): # ... 其他代码保持不变 ... def clean_city(self): city_id = self.cleaned_data.get('city') if not city_id: return city_id try: # 直接查询数据库验证城市是否存在 return City.objects.get(id=city_id) except City.DoesNotExist: raise forms.ValidationError("请选择有效的城市")
方法2:在表单验证阶段动态更新queryset
在表单的clean方法中,根据提交的省份ID动态更新city字段的queryset,确保验证时可选范围包含当前省份下的所有城市:
class AddressForm(ModelForm): # ... 其他代码保持不变 ... def clean(self): cleaned_data = super().clean() province_id = cleaned_data.get('province') if province_id: # 更新city的queryset为当前省份下的所有城市 self.fields['city'].queryset = City.objects.filter(province=province_id) return cleaned_data
方法3:初始化表单时传入请求,动态设置queryset
如果需要在表单初始化时就根据当前请求的省份参数设置city的queryset(适用于编辑场景或带参数的新建场景):
class AddressForm(ModelForm): def __init__(self, *args, **kwargs): self.request = kwargs.pop('request', None) super().__init__(*args, **kwargs) self.fields["province"].queryset = Province.objects.all() # 根据请求中的省份参数动态加载城市 if self.request and self.request.POST.get('province'): self.fields["city"].queryset = City.objects.filter(province=self.request.POST.get('province')) else: self.fields["city"].queryset = City.objects.none() # ... 其他代码保持不变 ...
同时需要在视图中传入request对象:
class AddressCreateView(CreateView): def get_form_kwargs(self): kwargs = super().get_form_kwargs() kwargs['request'] = self.request return kwargs
内容的提问来源于stack exchange,提问作者lukphin
相关产品推荐
相关产品推荐

