Django :: making a date dropdown field

It seems like it is hard to input valid dates in a free-form text format for a significant portion of users. Personally, I don’t like many of the popup calendar widgets since they usually rely on Javascript for rendering — making your form useless to people that don’t use Javascript in their browser. (To Javascript, or not to Javascript is a discussion for another time, but I’m quite sure Javascript shouldn’t be used for pretty graphic effects.) Quite a number of the Javascript calendar widgets I’ve seen only allow you to skip backwards one month at a time, and that gets really tedious if you’re as old as me and trying to find your birthday 😉

An easy alternative is the traditional dropdown lists for months, days, and years. One way to implement that in Django is of course to have a separate SelectField for months, days, years and leave it to the template code to lay them out sensibly. I don’t like that solution for several reasons: it’s too much complexity at the wrong abstraction layer (I want to deal with a date-widget as one unit in my template code), formatting according to local customs becomes harder, and it will end up sprinkling conversion code all over your app if you’re not careful. It turns out that it’s not that hard to create a custom widget that does it all for you automatically…

Note: This code is minimalist in many ways. E.g. using localized month names, localized day/month/year ordering, using empty defaults, etc. is left as an excercise for the reader.

First lets define a convenience function for creating a dropdown list (we’ll be responsible for the raw html in our widget, so it’s probably not a good idea to use an existing Django Field — I haven’t tried that though, so I might be wrong):

      def select(options, selected, name):
          selected = str(selected)
          options = [(str(v),str(v)) for v in options]
          res = ['<select name="%s">' % name]
          for k,v in options:
              tmp = '<option '
              if k == selected:
                  tmp += 'selected '
              tmp += 'value="%s">%s</option>' % (k,v)
              res.append(tmp)
          res.append('</select>')
          return '\n'.join(res)

Now for the DropDown (DD) DateField (it’s about 35 LOC without all the explanatory comments):

      class DDDateField(forms.FormField):
          # you might want to add a year range to this...
          def __init__(self, field_name='', is_required=False):
              self.field_name = field_name
              self.validator_list = [self.validator]
              self.is_required = is_required
       
              # for convenience (we'll insert html select tags into the
              # form to hold year/month/day data, and we'll name
              # them as follows):
              self.dayname = field_name + '_day'
              self.monthname = field_name + '_month'
              self.yearname = field_name + '_year'       
       
          # extract_data is called with a copy of the POST data.
          # Remember that all the fields will be empty the first time
          # around and that POST data is always text (and so should
          # any default values that you set be).
          # The return value from this method is later passed on to
          # the render method (after the user has had an opportunity
          # to mess with it).
          # NOTE: if anything throws here, your widget won't show up.
          def extract_data(self, post):
              name = self.field_name
              day = post.get(self.dayname, '1')
              month = post.get(self.monthname, '1')
              year = post.get(self.yearname, '1970')
              value = post.get(name, '%s-%s-%s' % (year, month, day))
              return dict(day=day, month=month, year=year, value=value)
       
          # now for the actual rendering. We're getting data from extract_data (above).
          def render(self, data):
              name = self.field_name
              vals = (self.get_id(), name, forms.escape(data['value']))
       
              # put a hidden input tag here fun fun (we'll have to overwrite this value
              # later..)
              h = '<input type=hidden id="%s" name="%s" value="%s" />' % vals
       
              # using the convenience function from above
              day = select(range(1,32), data['day'], self.dayname)
       
              # I haven't checked if it's possible to get localized month names from
              # the calendar module, but if it is, they could go here:
              month = select(range(1,13), data['month'], self.monthname)
              year = select(range(1920, 2007), data['year'], self.yearname)
              slash = '&nbsp;/&nbsp;'
       
              # use US ordering
              widget = h + str(month) + slash + str(day) + slash + str(year)
       
              return widget
       
          # Unfortunately there is no way for the hidden field to get updated
          # on a successful submission since render isn't called. The validator
          # is called at this point though, and it has access to the entire set
          # of form data. We're going to hijack it to tie up our lose ends
          def validator(self, field_data, alldata):
              # get the values from the subwidgets
              y = alldata[self.yearname]
              m = alldata[self.monthname]
              d = alldata[self.dayname]
       
              # set the fieldname to the combined value
              val = '%s-%s-%s' % (y,m,d)
              alldata[self.field_name] = val
       
              # delete all the gunk we put in the form
              del alldata[self.yearname], alldata[self.monthname], alldata[self.dayname]
       
              # there is no way for a user to enter an invalid date, so just
              # return True.
              return True
         
          # now all that's left for html2python is to extract data from above
          # and return a Python object.
          @staticmethod
          def html2python(data):
              # we're getting the data from the validator...
              y, m, d = map(int, data.split('-'))
              return datetime.date(y,m,d)

That’s it, it can be used as any other field.

This entry was posted in django. Bookmark the permalink.

Leave a Reply

Your email address will not be published. Required fields are marked *