You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.28 15:49:51