Dynamic ModelForms in Django

Posted by Ross Poulton on Mon 15 December 2008 #django #forms #modelform #newforms

For an ongoing project I am implementing basic advertising functionality, where I define a number of positions in the page and advertisers can self-serve to create an advertisement to fit those positions.

Each 'Position' can have different attributes turned on or off. For example, a 'sidebar' ad may permit an image and link text, however a 'footer' ad may only contain link text. When the user creates their own ad, I wanted a single form that morphed itself based on the Position being used so that fields were enabled or disabled, and made mandatory as required.

To get started, here are excerpts from the two models I'll use to demonstrate how I achieved this:

    # models.py
    from django.db import models

    class Position(models.Model):
        title = models.CharField(max_length=100)
        description = models.TextField()
        has_title = models.BooleanField(blank=True, null=True)
        has_summary = models.BooleanField(blank=True, null=True)
        has_link = models.BooleanField(blank=True, null=True)
        has_image = models.BooleanField(blank=True, null=True)

    class Advertisement(models.Model):
        position = models.ForeignKey(Position)
        internal_name = models.CharField(max_length=150)
        image = models.ImageField(upload_to=ad_image_path, blank=True, null=True)
        link_url = models.URLField(blank=True, null=True)
        link_text = models.CharField(max_length=100, blank=True, null=True)
        ad_text = models.CharField(max_length=100, blank=True, null=True)

The trick here is to ensure that when creating an Advertisement, the image, link_url, link_text and ad_text fields are turned on and off based on the related Position.

First things first: I created a ModelForm instance for my Advertisement model:

    class AdvertisementForm(forms.ModelForm):
        class Meta:
            model = Advertisement

This basic form shows all fields from the Advertisement model to the user, which wasn't exactly what I wanted. My final AdvertisementForm class made use of the __init__() method to remove irrelevant fields, and make any remaining fields required.

    # forms.py
    class AdvertisementForm(forms.ModelForm):
        def __init__(self, *args, **kwargs):

            self.position = kwargs['position']
            del kwargs['position']
            super(AdvertisementForm, self).__init__(*args, **kwargs)

            if not self.position.has_title:
                del self.fields['link_text']
            else:
                self.fields['link_text'].required = True

            if not self.position.has_summary:
                del self.fields['ad_text']
            else:
                self.fields['ad_text'].required = True

            if not self.position.has_link:
                del self.fields['link_url']
            else:
                self.fields['link_url'].required = True

            if not self.position.has_image:
                del self.fields['image']
            else:
                self.fields['image'].required = True

        class Meta:
            model = Advertisement
            exclude = ('position',)

You'll notice that I am using the position keyword argument to the form to determine which fields to show and make required. Obviously this turns the usage of this form into a two-step process: Firstly the user must select a position, then the relevant form is displayed to them based on their selection. This means that the in-view usage is slightly different to how we usually use forms in Django:

    # views.py
    def create_ad(request):
        if request.REQUEST.get('position', None):
            # This matches both request.GET and request.POST
            position = Position.objects.get(slug=request.REQUEST.get('position'))

            if request.method == 'POST':
                post_data = request.POST.copy()
                del(post_data['position'])
                if position.has_image:
                    form = AdvertisementForm(post_data, request.FILES, position=position)
                else:
                    form = AdvertisementForm(post_data, position=position)

                if form.is_valid():
                    ad = form.save(commit=False)
                    ad.position = position
                    ad.save()

                    return HttpResponseRedirect(reverse('ad_listing'))
            else:
                form = AdvertisementForm(position=position)

            return render_to_response("create_ad.html", RequestContext(request, {
                'position': position,
                'form': form,
            }))
        else:
            positions = Position.objects.filter()
            return render_to_response("select_position.html", RequestContext(request, {
                'positions': positions,
            }))

This view shows the two-step process that's now involved for the user:

  1. They are shown select_position.html, a very simple template:
        <ul>{% for position in positions %}
            <li><a href='./?position={{ position.slug }}'>{{ position.title }}</a></li>{% endfor %}
        </ul>
  1. The user selects a position, and the slug is passed back into the view as a GET parameter.
  2. The view grabs the Position object from the database based on the slug
  3. The form is initialised, passing the position keyword paramater
  4. The form sets self.position to save the position within the AdvertisementForm instance, then deletes the parameter from the keyword options.
  5. Normal form initialisation then occurs (via the Super call)
  6. The position is then checked to see if the has_title paramater is set. If it is, then the link_text field is made mandatory. If it isn't, then the link_text field is deleted from the form.
  7. This happens again for the ad_text, link_url and image fields.
  8. The form is rendered to the user with a neat little loop within the create_ad.html template:
        <form method='post' action='./'{% if form.is_multipart %} enctype='multipart/form-data'{% endif %}>
        <dl>
            {% for field in form %}
                {% if field.is_hidden %}
                    {{ field }}
                {% else %}
                    <dt>{{ field.label_tag }}</dt>
                    <dd>{{ field }}</dd>
                    {% if field.help_text %}<dd class='help_text'>{{ field.help_text }}</dd>{% endif %}
                    {% if field.errors %}<dd class='errors'>{{ field.errors }}</dd>{% endif %}
                {% endif %}
            {% endfor %}

            <dd><input type="submit" value="Add Item" /></dd>
        </dl>
        <input type="hidden" name="position" value="{{ position.slug }}" />
        </form>
  1. When the user clicks 'Submit', the form initialisation is again run before the form is validated, using the POSTed 'position' variable.. This means that validation will be run against the modified form, not against the base form.

This is all pretty straightforward, but it seems to be missed by many people trying to build custom forms. It's very valuable to remember that if you modify a form in it's __init__() method, those modifications will be done to both the unbound form and then to the bound form, so any modifications will impact upon your form validation.

Snazzy.