2008-12-15

Clarification for Confusion: Creating New Django Fields

Let's say you've just published your first Django-based project. You've learned a lot, you know how to create a new app, views, forms, middleware, and template tags. That's cool, but there is still much space for making your code cleaner and better-organized. One of such improvements could be joining multiple fields into a single field.

To be clear, there are several types of fields in Django web development and all of them are related to each other. First of all, there are the fields of database tables in the lowest level. Then there are model fields representing the database fields in python. Form fields go on top in the abstraction level. Then there are bound fields which bound the form fields with data from the database (or somewhere else). And finally there are HTML fields which represent the rendered bound fields and which can be spiced up with Javascript for creating new widgets.

Now imagine, that you have a Contact model which includes two phone numbers where each of them has three parts. They are the country code, area code, and the serial number. In the simplest case you would have something like this:

COUNTRY_CODE_CHOICES = getattr(settings, "COUNTRY_CODE_CHOICES", (
("+44", "United Kingdom (+44)"),
("+49", "Germany (+49)"),
("+370", "Lithuania (+370)"),
))

class Contact(models.Model):
user = models.OneToOneField(User)
website = models.URLField(_("Website"))
primary_phone_country = models.CharField(
_("Primary Country Code"),
max_length=4,
choices=COUNTRY_CODE_CHOICES,
)
primary_phone_area = models.CharField(
_("Primary Area Code"),
max_length=4,
)
primary_phone_number = models.CharField(
_("Primary Number"),
max_length=10,
)
secondary_phone_country = models.CharField(
_("Secondary Country Code"),
max_length=4,
choices=COUNTRY_CODE_CHOICES,
blank=True,
)
secondary_phone_area = models.CharField(
_("Secondary Area Code"),
max_length=4,
blank=True,
)
secondary_phone_number = models.CharField(
_("Secondary Number"),
max_length=10,
blank=True,
)


But it seems to be such a mess when it could be something like this:

class Contact(models.Model):
user = models.OneToOneField(User)
website = models.URLField(_("Website"))
primary_phone = PhoneField(_("Primary Phone"))
secondary_phone = PhoneField(_("Secondary Code"), blank=True)


So the question now is how to create a model field that would be saved as three separate database fields and would be shown as three bound fields when editing. Let's have a look what information is available about custom model fields. There is a page in the documentation which might help us here. Also there are articles by David Cramer and Michael Elsdörfer which might help to solve this problem. In addition, I found a snippet by Myles Braithwaite which also deals about custom model fields.

Combining the ideas from the given sources with the analysis of the Django core code, I defined the PhoneField in the following way:

# -*- coding: utf-8 -*-
from django.db import models
from django.conf import settings
from django.utils.translation import ugettext_lazy as _

PHONE_COUNTRY_CODE_CHOICES = getattr(settings, "PHONE_COUNTRY_CODE_CHOICES", (
("+44", _("United Kingdom (+44)")),
("+49", _("Germany (+49)")),
("+370", _("Lithuania (+370)")),
))

class PhoneField(models.Field):
def __init__(self, verbose_name=None, country_code_choices=PHONE_COUNTRY_CODE_CHOICES, *args, **kwargs):
super(PhoneField, self).__init__(*args, **kwargs)
self.country_code_choices = country_code_choices
self.verbose_name = verbose_name

def contribute_to_class(self, cls, name):
self.name = name
if self.verbose_name is None and name:
self.verbose_name = name.replace('_', ' ')
# creating three model fields on the fly
models.CharField(
_("%s Country Code") % self.verbose_name,
max_length=4,
choices=self.country_code_choices,
blank=self.blank,
).contribute_to_class(cls, "%s_country" % name)
models.CharField(
_("%s Area Code") % self.verbose_name,
max_length=4,
blank=self.blank,
).contribute_to_class(cls, "%s_area" % name)
models.CharField(
_("%s Number") % self.verbose_name,
max_length=10,
blank=self.blank,
).contribute_to_class(cls, "%s_number" % name)
# when accessing the phone field by original model field name,
# we'll manage tuples of country code, area code, and number
setattr(cls, self.name, PhoneFieldCreator(self))

class PhoneFieldCreator(object):
def __init__(self, field):
self.field = field

def __get__(self, obj, type=None):
if obj is None:
raise AttributeError('Can only be accessed via an instance.')
country = obj.__dict__.get("%s_country" % self.field.name, None)
area = obj.__dict__.get("%s_area" % self.field.name, None)
number = obj.__dict__.get("%s_number" % self.field.name, None)
return (country and area and number) and (country, area, number) or None

def __set__(self, obj, value):
if isinstance(value, tuple) and len(value) == 3:
setattr(obj, "%s_country" % self.field.name, value[0])
setattr(obj, "%s_area" % self.field.name, value[1])
setattr(obj, "%s_number" % self.field.name, value[2])


Note that the instances of the model with the PhoneField can be filtered by any part of the phone number. In addition, the field value can be accessed as a tuple of three parts of the number. For example:

>>> Contact.objects.filter(primary_phone_country="+370")[0].primary_phone
(u'+370', u'628', u'12345')


Now the phone number can be presented in different ways in templates depending on the business requirements or local flavor:


{{ obj.primary_phone|join:"" }}
{{ obj.primary_phone|join:" " }}
{{ obj.primary_phone|join:"-" }}
{{ obj.primary_phone.0 }} ({{ obj.primary_phone.1 }}) {{ obj.primary_phone.2 }}
{{ obj.primary_phone_country }} ({{ obj.primary_phone_area }}) {{ obj.primary_phone_number }}