ModelChoiceFields and Default Empty Values

Hi, I’m using a ModelChoiceField to create a dropdown in a form and can’t get a “empty value” to show up despite having required=False and blank=True set.

I’ve tried adding an empty value explicitly using

self.fields[self.__FIELD_KEY].choices.insert(0, (0, "---"))
self.fields[self.__FIELD_KEY].initial = 0

but that doesn’t work either. The empty value doesn’t even show up in the generated html.

Is there a trick to getting this to show up with ModelChoiceFields?

A ModelChoiceField gets its options from the queryset and not the choices attribute. If you have required=False and blank=True defined in the form field (and not set after-the-fact), then the blank entry should be there.

If you’re seeing some other behavior for this, then we’d probably need to see the complete form to try and recreate / verify this.

That was my understanding too. Ok, something that might be complicating this is that I’m trying to use Mixins to spread form fields across several classes so I don’t have a huge form class.

Here’s the parent form:

class GlobalProjectNewForm(
    ModelForm,
    HubspotDealFormMixin,
    JiraProjectFormMixin,
):
    
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        HubspotDealFormMixin.__init__(self, False)
        JiraProjectFormMixin.__init__(self, False)
       
    def save(self, commit=True):
        project = super().save(commit=commit)
        HubspotDealFormMixin.save_new(self, project)
        JiraProjectFormMixin.save_new(self, project)
        return project

    class Meta:
        model = GlobalProject
        fields = ("name", "is_internal", "notes")

Here’s the JiraProjectMixin

class JiraProjectFormMixin(FormMixin):

    __queryset = JiraProject.objects.all().order_by("name")  

    jira_project_selector = ModelChoiceField(
        label="Link to Jira Project",
        queryset=__queryset,
        widget=Select(attrs={"class": "form-jira-project-selector"}),
        required=False,
        blank=True,
    )  

    __FIELD_KEY = f"{jira_project_selector=}".split("=")[0]

    def __init__(self, is_edit: bool = False):
        if is_edit and hasattr(self.instance, "pk"):
            self._initial_assigned_jira_project = JiraProject.objects.filter( 
                global_project_id=self.instance.pk
            ).values_list("id", flat=True)

            self.fields[self.__FIELD_KEY].choices = self.__queryset.filter(
                (
                    Q(global_project=None)
                    | Q(global_project_id=self.instance.pk)  
                )
                & Q(is_private=False)
            ).values_list("id", "name")
            self.fields[self.__FIELD_KEY].initial = self._initial_assigned_jira_project.first()
        else:
            self.__queryset = JiraProject.objects.filter(
                Q(global_project=None) & Q(is_private=False)
            ).order_by("name")
            self.fields[self.__FIELD_KEY].choices = self.__queryset.values_list("id", "name")

    def save_new(self, project: GlobalProject) -> None:
        jira_project = self.cleaned_data[self.__FIELD_KEY]
        jira_project.global_project = project
        jira_project.save(update_fields=["global_project", "updated_at"])

    def save_edit(self, project: GlobalProject) -> None:
        selected_jira_project = self.cleaned_data[self.__FIELD_KEY]
        # There should only be 1 project...
        current_jira_project = JiraProject.objects.get(  
            pk=self._initial_assigned_jira_project.first()
        )
        if selected_jira_project != current_jira_project:
            if current_jira_project:
                current_jira_project.global_project = None
                current_jira_project.save(update_fields=["global_project", "updated_at"])
            selected_jira_project.global_project = project
            selected_jira_project.save(update_fields=["global_project", "updated_at"])

    @property
    def data_key(self) -> str:
        return self.__FIELD_KEY

This actually seems to work for adding new projects and linking them to existing Jira projects in our database. There’s a similar form for editing that also works (the mixin classes get used for both.) The last piece I can’t figure out is how to get an empty selection here – there doesn’t have to be a connection between a Jira Project and a Global Project in our model. Same for HubSpot deals (but that’s a multiple choice field you’ve seen before :wink: )

Clearly, I’m using the choices and initial properties above, not sure if I should be but it seems to work for everything except having an empty value?

My only suggestion here would be for you to walk through the complete process of creating an instance of one of these forms using a debugger to understand exactly how this is getting initialized - and at what point those options are being initialized.

1 Like

If I don’t set up the choices like I am currently in the __init__ I do get an empty value fwiw. Still playing around with that.

I’ve been working with this in a debugger, but one thing I don’t understand even while stepping through it is how the jira_project_selector gets hoisted up onto the fields object on the parent form?

I’m not sure I understand what you’re asking here.

GlobalProjectNewForm is a class. It consists of all attributes and methods defined in the aggregate of ModelForm, HubspotDealFormMixin, and JiraProjectFormMixin.

Note: Normally, you want mixins to supercede the behavior of the base class which means you want to specify the mixins before the base class. If the reasons behind this aren’t clear, you may want to review the documentation on Python’s MRO (Method Resolution Order) as it applies to class structures.

Thanks for the tips, figured it out. I don’t need to fiddle with the choices on a ModelChoiceField, all I needed to do was update the self.__queryset in the __init__ of my Mixin to be what values I want then Django creates the empty fields just fine. I don’t know why I was afraid to mess with the queryset directly in the __init__, but seems like that’s how it’s supposed to done.

Maybe I’m misunderstanding how Django works. It seems like any class variables in a forms subclass get added to the base Model.fields object but maybe there’s something obvious I’m missing (not the first time.)

Yes, typically you want Mixins to follow the base class, but in this case I want the base class to setup the fields and related objects that the Mixins can modify, that’s why I’m directly calling their constructors and don’t have an __init__ defined on the abstract base class of the Mixins. I think this last point is the gotcha, I wanted an abstract base class for the Mixins so that there is a pattern to their implementation ( a set of properties and methods that must exist), if each Mixin was standalone (i.e., no base class beyond object) then they could each call super().__init__ and eventually initialize the Model base class.

Back to the original question, it’s strange to me that ModelMultipleChoiceField seems to require the choices object being setup. I can’t get it to work by only manipulating the corresponding queryset.

<conjecture>
I’m guessing it’s got something to do with the order that things are being initialized. My gut reaction to this is that objects aren’t being created or allocated the way they’re supposed to be, leading to potentially anomalous results.
</conjecture>

So no, I don’t have any specific knowledge or insight as to what might be happening here, just the uneasy feeling that your entire structure has a “bad code smell” to me. I could well be wrong - this may be just fine.

That’s fair – I’m going to revisit how I’m setting up the Mixins, might be able to do it the traditional way now that I’ve figured out the root of my original problem.