Mimsy Were the Borogoves

Hacks: Articles about programming in Python, Perl, PHP, and whatever else I happen to feel like hacking at.

Django forms and edit_inline models

Jerry Stratton, November 23, 2007

In my Django models I tag just about everything except tags themselves. Most of my models have a counterpart that looks like this:

[toggle code]

  • class PageKey(models.Model):
    • keyword = models.ForeignKey(Tag, core=True)
    • page = models.ForeignKey(Page, edit_inline=models.TABULAR, min_num_in_admin=4, num_extra_on_change=2)
    • class Meta:
      • unique_together = (('page', 'keyword'),)
    • def __str__(self):
      • return self.page.title + " : " + self.keyword.title

This example adds an indefinite number of tags to each Page; in HTML parlance, these are the page’s keywords.

Adding them in the built-in administrative interface in Django is easy; Django shows them inline with the page’s entry/edit interface. In a custom form, adding a single select menu based on another model is also easy, using ModelChoiceField. ModelChoiceField automatically adds a select menu with the IDs and names based on a queryset, such as Tags.objects.all().

But adding indefinite inline model-based select menus to a custom form is more difficult. The answer appears to be to use MultiValueField and MultiWidget. The documentation is sparse at best on the subject, and the pieces necessary to do it are hidden in the source code and cryptic Google Groups postings. It took me a long time to figure this out, so I’m documenting it here to (a) make it easier for others, and (b) find out what I did wrong.

I did this in Django 0.96.

The basic edit page

One of the many nice things about Django is that the template system makes it easy to subclass templates. So in this example, I have a template for displaying pages, and I can base a new template off of that for editing pages. The edit page will have the same shell as the normal view page, with the content swapped out for a form for editing the page’s title, keywords, description, and content.

[toggle code]

  • {% extends "pages/subpage.html" %}
  • {% block headers %}
    • {{ block.super }}
    • <link rel="stylesheet" type="text/css" href="http://www.hoboes.com/css/edit.css" />
  • {% endblock %}
  • {% block content %}
    • {{ form.errors }}
    • <form method="POST" action="/pages/edit/{{ page.slug }}/">
      • <input type="submit" name="save" value="Save Changes" />
      • <div class="editcontent">
        • <p>Title:{{ form.title.help_text }}
        • {{ form.title }}</p>
        • {{ form.errors.title }}
        • <p>Keywords:{{ form.keywords.help_text }}
        • {{ form.keywords }}</p>
        • {{ form.errors.keywords }}
        • <p>Short description:{{ form.description.help_text }}<br />
        • {{ form.description }}</p>
        • {{ form.errors.description }}
        • <p>Main content:{{ form.content.help_text }}<br />
        • {{ form.content }}</p>
        • {{ form.errors.content }}
      • </div>
    • </form>
  • {% endblock %}

So, to display this, I’m going to need to do something like this:

[toggle code]

  • from django.shortcuts import get_object_or_404, render_to_response
  • from django.contrib.auth.decorators import login_required
  • from django import newforms as forms
  • from mysite.pages.models import Page, PageKey, Tag
  • @login_required
  • def editpage(request, page_slug):
    • page = get_object_or_404(Page, slug=page_slug)
    • pageForm = CMSForm(page=page, request=request)
    • pageForm.post_changes()
    • pageContext = {'page': page, 'form': pageForm}
    • return render_to_response("edit/subpage.html", pageContext)

This is pretty basic, and if you’re looking at making custom Django forms you should already understand everything except for the pageForm parts. You’ll need to set this up in urls.py just as you would any other view.

Customizing forms.Form

In Django, a lot of form management can be pushed off into forms.Form (as I write this, this is specifically newforms.Form). Subclassing forms.Form to handle simple text fields is very easy.

[toggle code]

  • class CMSForm(forms.Form):
    • title = forms.CharField(max_length=120)
    • content = forms.CharField(widget=forms.Textarea, required=False)
    • description = forms.CharField(max_length=500, widget=forms.Textarea)
    • def __init__(self, page, request, **kwargs):
      • self.page = page
      • self.request = request
      • #if we just submitted a form, the data comes from the form
      • #otherwise, it comes from the page itself
      • if self.request.method == "POST":
        • kwargs['data'] = self.request.POST
      • else:
        • pagedata = {'title': self.page.title, 'content': self.page.content, 'description': self.page.description}
        • kwargs['data'] = pagedata
      • super(CMSForm, self).__init__(**kwargs)
      • #self.initialize_tags(self.request.method)

This example creates a text input for title, and a textarea for content and description. I’ve commented out the tag initialization call, because we haven’t created that yet, and that’s more complex. You’ll want to make sure you understand the simple stuff before you go on to the harder stuff.

The editpage function also calls “post_changes” on the CMSForm.

[toggle code]

    • def post_changes(self):
      • if self.request.method == 'POST':
        • if self.is_valid():
          • changed = False
          • title = self.cleanText('title')
          • content = self.cleanText('content')
          • description = self.cleanText('description')
          • if title != self.page.title:
            • self.page.title = title
            • changed = True
          • if content != self.page.content:
            • self.page.content = content
            • changed = True
          • if description != self.page.description:
            • self.page.description = description
            • changed = True
          • if changed:
            • self.page.save()
          • #self.save_tags()
    • def cleanText(self, field):
      • text = self.clean_data[field]
      • if type(text) == unicode:
        • text = text.encode("utf-8")
      • return text

Again, I’ve commented out the save_tags call. We’ll get to that in just a bit. This is otherwise pretty simple. When asked to save changes, it first checks to see if a form has been submitted; it then checks to see if the form submission is valid; and then it checks to see if the form submission actually changed anything.

The reason I do an “encode("utf-8")” on each piece of text is that otherwise, the comparison always comes back unequal. This is probably something I’m doing wrong on my HTML page or in my Model; I’ve never really understood encode/decode on Python strings. So you may or may not need that function.

MultiValueField and MultiWidget

Without MultiValueField, an indefinite number of keywords would require checking against an indefinite number of form fields. With MultiValueField, a lot of that code is handled for us. This, however, is where things start getting sticky. This works for me; whether it is right is another story entirely.

What I’ve done is subclass MultiValueField to accept the number of existing tags. The form will need this many select menus, plus a few more for entering additional tags. Each select menu shows every tag in the Tag model.

[toggle code]

  • class CMSTagFields(forms.MultiValueField):
    • def __init__(self, tagcount=0):
      • formField = forms.ModelChoiceField(queryset=Tag.objects.all())
      • choices = formField._get_choices()
      • tagcount = tagcount + 2
      • fields = (formField,)*tagcount
      • widgets = CMSTagWidget(choices=choices, tagcount=tagcount)
      • super(CMSTagFields, self).__init__(fields, widget=widgets, required=False)
    • def compress(self, data_list):
      • return data_list

Selects from a model are handled by ModelChoiceField. One of the weird bits about this is that while ModelChoiceField normally defaults to using a Select widget, we still have to tell MultiValueField that it needs to use Select widgets for each of them. I don’t know why we need to tell it to use what it would have normally used anyway (outside of a MultiValueField) but if we don’t, we just get a text input field.

So, I’ve subclassed MultiWidget as well:

[toggle code]

  • class CMSTagWidget(forms.MultiWidget):
    • def __init__(self, choices=(), tagcount=0):
      • widgets = (forms.Select(choices=choices),)*tagcount
      • super(CMSTagWidget, self).__init__(widgets)
    • def decompress(self, value):
      • return value

The “compress” and “decompress” methods are used for returning the data in different formats if necessary. For example, probably the canonical use of MultiValueField is for a combination date and time entry. It will be displayed on the form as two fields: one for the date, and one for the time. But the “compress” method will take both of those values and return a timestamp or a SQL datetime.

The “decompress” method (I think) is the opposite: it takes the single value and returns the multiple values that make it up. For example, taking a SQL datetime and returning the date and the time that each need to be displayed on the form.

In our case, we don’t want to do any compression. If there’s a list of tags, we want that list of tags back. However, those methods are required, so in the method I just return the value back.

Creating indefinite selects

We still have the problem that even MultiValueField expects a known number of values. But the number of values for an edit_inline model is not known.

The form model stores form fields in a dictionary called “fields”. We can insert extra form fields into that dictionary. I added this method to the CMSForm model:

[toggle code]

    • #add keywords choices
    • def initialize_tags(self, method=None):
      • tagcount = 0
      • if self.page:
        • taglist = Tag.objects.filter(pagekey__page=self.page).values('id')
        • if taglist:
          • if method == "POST":
            • tagcount = len(taglist)
          • else:
            • #if the form has not been submitted, we need to fake it
            • for tag in taglist:
              • tagkey = "keywords_" + str(tagcount)
              • self.data[tagkey] = tag['id']
              • tagcount = tagcount + 1
      • self.fields['keywords'] = CMSTagFields(tagcount)

If you’re following along at home, remember to uncomment the initialize_tags call in CMSForm’s “__init__” method.

The tricky bit here is that not only are we adding a MultiValueField called “keywords” to the form, we also need to get the tag IDs in so that each select menu defaults to the correct tag. In a MultiValueField, the field names are the base field name, with an underscore and number appended to it. For example, since I call this one keywords, if there are three keyword select menus they will be keywords_0, keywords_1, and keywords_2.

Just as the list of form fields are in a dictionary called “fields”, the form data is in a dictionary called “data”. We can add our own fake data into it. There’s no need to add fake data in if the form was just submitted—Django does that for us.

There’s probably a better way of doing this with “initial” data, but I couldn’t get that to work.

Using indefinite selects

Using the results of MultiValueField is easier than making the fields. The clean_data method on a form object doesn’t just contain the IDs of the tags, it contains the actual Tag model records. This means we can loop through what the form gives us and compare against what the page actually has.

[toggle code]

    • def save_tags(self):
      • keywords = self.cleanText('keywords')
      • prev_tags = Tag.objects.filter(pagekey__page=self.page)
      • #what tags need to be removed?
      • for tag in prev_tags:
        • if not tag in keywords:
          • pagekey = PageKey.objects.get(page=self.page, keyword=tag)
          • pagekey.delete()
      • #what tags need to be added?
      • for tag in keywords:
        • if tag and not tag in prev_tags:
          • PageKey.objects.create(page=self.page, keyword=tag)

Again, if you’re using this on your own models, don’t forget to uncomment the save_tags call in CMSForm’s “post_changes” method.

December 7, 2007: MultiWidgets and templates in Django

MultiValueFields and MultiWidgets are easy to use for making simple combined inputs such as two adjacent text boxes, but what if the data needs more than just multiple inputs strung together? Text, for example, will often be necessary to describe the function of each input.

Templates can be used for custom inputs just as they’re used everywhere else in Django. In a MultiWidget, override the format_output method to provide the HTML. The method receives an array of the rendered inputs in the same order that they were specified when created.

Here’s a simple example that I used to create a changelog:

[toggle code]

  • class CMSChangeWidget(forms.MultiWidget):
    • def __init__(self):
      • widgets = (forms.HiddenInput(), forms.TextInput(), forms.Textarea())
      • super(CMSChangeWidget, self).__init__(widgets)
    • def decompress(self, value):
      • return value
    • def format_output(self, rendered_widgets):
      • widgetContext = {'ID': rendered_widgets[0], 'title': rendered_widgets[1], 'summary': rendered_widgets[2]}
      • return render_to_string("edit/parts/changelog.html", widgetContext)

This widget will have a hidden field for the changelog entry’s ID, an input of type “text” for the title of the change, and a textarea for a description of the change.

It can be used in a MultiValueField like this:

[toggle code]

  • class CMSChangeFields(forms.MultiValueField):
    • def __init__(self):
      • fields=(forms.CharField(max_length=4, label="ID"), forms.CharField(max_length=48, label="Change"), forms.CharField(max_length=400, label="Summary"))
      • widgets = CMSChangeWidget()
      • super(CMSChangeFields, self).__init__(fields, widget=widgets, required=False)
    • def compress(self, data_list):
      • return data_list

Don’t forget to include “from django.template.loader import render_to_string” to get the render_to_string function from Django.

  1. <- Scribus EPS
  2. Web Taskpaper ->