How to set dynamic values for Field choices’ human-readable names

Sept. 24, 2021

Tags

In this post, I explain how to add dynamic data to the choices shown in a Django form dropdown.

If you'd like to skip to the implementation, head down to the feature request section below.

Background

I was building an email blast feature in a client project. I won't go into the implementation of the email queueing system itself for now - we'll focus on the email blast model, which looked initially roughly like this:

class UserSegments(models.TextChoices):
    ACTIVE_UNDER_90_DAYS = "U", "Active users >> Completed task >> Under 90 days ago"
    ACTIVE_OVER_90_DAYS = "O", "Active users >> Completed task >> Over 90 days ago"
    NO_TASK_BUT_VERIFIED = "V", "Active users >> Not completed task >> Verified both SMS/email"
    NO_TASK_NOT_VERIFIED = "N", "Active users >> Not completed task >> Unverified SMS and/or email"

class EmailBlast(models.Model):
    created_at = models.DateTimeField(auto_now_add=True)
    send_to = models.CharField(choices=UserSegments.choices, max_length=1)
    subject = models.CharField(max_length=78)
    body = models.TextField(
        help_text="Use [first_name] to insert each user's first name we have on file"
    )
    queued = models.BooleanField(default=False)

We use a simple CreateView to create the blast with the chosen recipients:

class CreateEmailBlast(StaffuserRequiredMixin, CreateView):
    model = EmailBlast
    fields = ['send_to', 'subject', 'body']

    def get_success_url(self):
        messages.success(self.request, 'Email blast queued!')
        return reverse('email_blast:create')

...which renders with a nice dropdown that looks like this:

email_blast_form_1.png
The Feature Request

My client wanted to have a sense of how many people would get an email when they submitted this form. My first thought was to override the get_FOO_display method on the model (see Django docs), but that only works for instances of the model, and in a CreateView, we don't yet have an instance.

My next idea was to simply modify the string representations directly. However, as Django technical board member Adam Johnson helpfully pointed out to me on Twitter, this code would be executed at import time, so the counts would only be updated when restarting the web server. Adam actually opened a Django ticket with more explanation. You can read it here.

It turns out, perhaps unsurprisingly, the best place to adjust the choices displayed in the form is in the Form class itself. This means we actually have to create a form, rather than letting Django create the default one for us.

class EmailBlastForm(ModelForm):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.fields['send_to'].choices = self._get_send_to_choices()
    
    def _get_send_to_choices(self):
        recent = User.objects.recent().count()
        stale = User.objects.stale().count()
        verified_no_task = User.objects.verified_no_task().count()
        unverified = User.objects.unverified().count()
        user_counts = [recent, stale, verified_no_task, unverified]
        choices = UserTypes.choices
        msg = "EmailBlast.send_to.choices does not match EmailBlastForm assumption"
        assert [x for x, _ in choices] == ['U', 'O', 'V', 'N'], msg
        annotated_choices = [("", "---------")]
        for idx in range(len(choices)):
            code, human_readable = choices[idx]
            count = user_counts[idx]
            count_string = f"({count:,} users)"
            annotated_choices.append((code, f"{human_readable} {count_string}"))
        return annotated_choices


    class Meta:
        model = EmailBlast
        fields = ['send_to', 'subject', 'body']

Note the logic for the segments lives in the User model manager. These user segments are important throughout the app so it’s best to write the query logic only once. Also note the assert statement _get_send_to_choices. In the real codebase, this is in a unit test, but I've included it here to underscore that we need to make sure this helper method is changed if the model field's choices change.

Lets update our view to use the new form.

class CreateEmailBlast(StaffuserRequiredMixin, CreateView):
    model = EmailBlast
    form_class = EmailBlastForm

    def get_success_url(self):
        messages.success(self.request, 'Email blast queued!')
        return reverse('email_blast:create')

Now our form looks like this:

email_blast_form_2.png

Success! It's important to note that with this implementation, there is a race condition: we are simply displaying the counts in the form, but those counts could change between loading the form and submitting the form. In this case it doesn't matter, since the client was just looking for a general idea of the size of each group, but depending on your use case you may need to be more careful.

Return to blog