I keep getting the feeling that I’m benefitting from maybe half of the features of Django forms, but suffering greatly at the other half of the “features.”
Here’s an interesting use case. I have a form that allows a user to edit their “profile.” This contains a few objects, namely the following:
class UserProfile(models.Model):
default_address = models.ForeignKey("Address")
default_phone_number = models.ForeignKey("PhoneNumber")
class Address(models.Model):
name = models.CharField()
street_address = models.CharField()
street_address_2 = models.CharField()
city = models.CharField()
country = models.ForeignKey("locality.Country")
territory = models.ForeignKey("locality.Territory", blank=True, null=True)
postal_code = models.CharField()
class PhoneNumber(models.Model):
name = models.CharField()
number = models.CharField()
The “locality.*” models are from another project I wrote called django-locality, and are viewable here.
(I wrote django-locality as there simply wasn’t a way of doing what I wanted at the time. I was looking to simply create this form, which included a country and a territory. As there wasn’t anything that gave me database access to countries and their territories, I built something to do the job. I needed to allow users to select a country and only be able to select a territory for that country if the country had territories. Pretty simple, but it evidently hadn’t been done before.)
So here’s where things get a bit more complicated. My form edits django.contrib.auth.models.User‘s first_name and last_name fields, as well as creates or updates Address and PhoneNumber instances owned by the UserProfile class.
Validation gets really complicated really quickly. I need to make sure that 1. if a country has territories, a territory must be selected, and 2. if a territory is selected, it must belong to the selected country. Also, I ended up essentially providing a blank select control in my template, as territories have to be dynamically fetched based on the selected country. It would have been nice to simply have a form field like a “ModelOptgroupChoiceField” which would have allowed me to group my territories by their country’s abbreviation, in a select control with optgroups for each country then filter these out in JavaScript, but whatever. I was able to at least get it working after much deliberation and experimentation.
Another complication in validation comes with validation of phone numbers and postal-codes: how am I supposed to validate them? Sure, django.contrib.localflavors provides controls, but provides basically no single auto-localizing control to drop in. I could write some crazy logic which would use an input country’s abbreviation to look things up in the django.contrib.localflavors package and dynamically set my phone_number and postal_code fields in my form to the right values, but seriously? Do I need to go to a hack at that extreme of a length to get things working? I basically just gave up entirely on validation/formatting for these fields.
class ProfileEditForm(forms.Form):
default_error_messages = {
'invalid_territory': _("Please select a territory."),
'invalid_country': _("Please select a country."),
}
first_name = forms.CharField(max_length=30)
last_name = forms.CharField(max_length=30)
street_address = forms.CharField(max_length=128)
street_address_2 = forms.CharField(max_length=128, required=False)
city = forms.CharField(max_length=128)
country = forms.ModelChoiceField(Country.objects.all().order_by('name'),
empty_label=u'', to_field_name='iso2')
territory = forms.ModelChoiceField(Territory.objects.all().order_by(
'country__name', 'name'), empty_label=u'', to_field_name='pk')
zipcode = forms.CharField(max_length=12)
phone_number = forms.CharField(max_length=16)
def __init__(self, *args, **kwargs):
if 'user' in kwargs:
user = kwargs['user']
del kwargs['user']
kwargs['initial'] = {
'first_name': user.first_name,
'last_name': user.last_name,
'street_address': user.profile.default_address.street_address
if user.profile.default_address != None else '',
'street_address_2': user.profile.default_address.street_address_2
if user.profile.default_address != None else '',
'city': user.profile.default_address.city
if user.profile.default_address != None else '',
'country': user.profile.default_address.country.iso2
if user.profile.default_address != None else None,
'territory': user.profile.default_address.territory.pk
if user.profile.default_address != None else None,
'zipcode': user.profile.default_address.postal_code
if user.profile.default_address != None else '',
'phone_number': user.profile.default_phone_number.number
if user.profile.default_phone_number != None else None,
}
super(ProfileEditForm, self).__init__(*args, **kwargs)
def clean(self):
territory = self.cleaned_data.get('territory', None)
country = self.cleaned_data.get('country', None)
if territory == None or Territory.objects.filter(country__id = country.pk,
pk=territory.pk).count() == 0:
self._errors['territory'] = self.error_class([
self.default_error_messages['invalid_territory']])
if territory != None:
del self.cleaned_data.territory
else:
self.cleaned_data['territory'] = Territory.objects.get(
country__id = country.pk, abbr = territory.abbr)
# format phone-number
if re.match(r'^\d{10}$', self.cleaned_data['phone_number']):
match = re.match(r'^(\d{3})(\d{3})(\d{4})$', self.cleaned_data[
'phone_number'])
self.cleaned_data['phone_number'] = "%s-%s-%s" % (match.group(1),
match.group(2), match.group(3))
return self.cleaned_data
If you think my form is a bit complicated, wait until you see my template in order to output things properly:
<form method="post" action="">
<fieldset>
{% csrf_token %}
<legend>Your Name</legend>
<div class="clearfix{% if form.first_name.errors %} error{% endif %}">
<label for="first_name_input">First Name</label>
<div class="input">
<input id="first_name_input" name="first_name" class="span5" type="text"{% if form.first_name.value %} value="{{form.first_name.value}}"{% endif %}></input>
{{ form.first_name.errors }}
</div>
</div>
<div class="clearfix{% if form.last_name.errors %} error{% endif %}">
<label for="last_name_input">Last Name</label>
<div class="input">
<input id="last_name_input" name="last_name" class="span5" type="text"{% if form.last_name.value %} value="{{form.last_name.value}}"{% endif %}></input>
{{ form.last_name.errors }}
</div>
</div>
</fieldset>
<div class="row">
<div class="span7">
<fieldset>
<legend>Your Address</legend>
<div class="clearfix{% if form.street_address.errors %} error{% endif %}">
<label for="street_address_input">Address Line 1</label>
<div class="input">
<input id="street_address_input" name="street_address" class="span5" type="text"{% if form.street_address.value %} value="{{form.street_address.value}}"{% endif %}></input>
{{ form.street_address.errors }}
</div>
</div>
<div class="clearfix{% if form.street_address_2.errors %} error{% endif %}">
<label for="street_address_2_input">Address Line 2</label>
<div class="input">
<input id="street_address_2_input" name="street_address_2" class="span5" type="text"{% if form.street_address_2.value %} value="{{form.street_address_2.value}}"{% endif %}></input>
{{ form.street_address_2.errors }}
</div>
</div>
<div class="clearfix{% if form.city.errors %} error{% endif %}">
<label for="city_input">City</label>
<div class="input">
<input id="city_input" name="city" data-placeholder="Your City" class="span5"{% if form.city.value %} value="{{form.city.value}}"{% endif %}></input>
{{ form.country.errors }}
</div>
</div>
<div class="clearfix{% if form.country.errors %} error{% endif %}">
<label for="country_input">Country</label>
<div class="input">
<select id="country_input" name="country" data-placeholder="Choose a Country..."
class="chzn-select span5"{% if form.country.value %} data-initialvalue="{{form.country.value}}"{% endif %}>
<option value=""></option>
{% for country in countries %}
<option value="{{country.abbr}}"{% if form.country.value == country.iso2 %} selected{% endif %}>{{country.name}}</option>
{% endfor %}
</select>
{{ form.country.errors }}
</div>
</div>
<div class="clearfix{% if form.territory.errors %} error{% endif %}">
<label for="territory_input">Territory</label>
<div class="input">
<select id="territory_input" name="territory" data-placeholder="Choose a State..."
class="chzn-select span5" {% if form.territory.value %} data-initialvalue="{{form.territory.value}}"{% endif %}>
<option value=""></option>
</select>
{{ form.territory.errors }}
</div>
</div>
<div class="clearfix{% if form.zipcode.errors %} error{% endif %}">
<label for="zipcode_input">Postal Code</label>
<div class="input">
<input id="zipcode_input" name="zipcode" class="span5" text="text"{% if form.zipcode.value %} value="{{form.zipcode.value}}"{% endif %}></input>
{{ form.zipcode.errors }}
</div>
</div>
</fieldset>
</div>
</div>
<fieldset>
<legend>Your Phone Number</legend>
<div class="clearfix{% if form.phone_number.errors %} error{% endif %}">
<label for="phone_input" text="text">Phone Number</label>
<div class="input">
<input id="phone_input" name="phone_number" class="span5" text="text"{% if form.phone_number.value %} value="{{form.phone_number.value}}"{% endif %}></input>
{{ form.phone_number.errors }}
</div>
</div>
</fieldset>
<div class="actions clearfix">
<input type="submit" class="btn primary" style="float:right" value="Save Changes"></input>
</div>
</form>
As if that’s not enough, my view is likewise bloated and complicated:
@login_required
def profile_edit(request):
if request.method == "POST":
form = forms.ProfileEditForm(request.POST)
if form.is_valid() == True:
user = request.user
profile = user.profile
user.first_name = form.cleaned_data['first_name']
user.last_name = form.cleaned_data['last_name']
user.save()
address = profile.default_address or models.Address()
address.name = "Default" if address.name == None else address.name
address.street_address = form.cleaned_data['street_address']
address.street_address_2 = form.cleaned_data['street_address_2']
address.city = form.cleaned_data['city']
address.country = form.cleaned_data['country']
address.territory = form.cleaned_data['territory']
address.postal_code = form.cleaned_data['zipcode']
address.user_profile = profile
address.save()
phone_number = profile.default_phone_number or models.PhoneNumber()
phone_number.name = "Default" if phone_number.name == None else phone_number.name
phone_number.number = form.cleaned_data['phone_number']
phone_number.user_profile = profile
phone_number.save()
profile.default_address = address
profile.default_phone_number = phone_number
profile.save()
return redirect("/me/profile/")
else:
form = forms.ProfileEditForm(user=request.user)
return dto(request, "desktop/profile/edit.html", {"form": form,
"countries": Country.objects.all().order_by('name'),
"territories": Territory.objects.all().order_by('country__iso2')})
All-in-all, it’s taken well over 12 hours to write this form, excluding the amount of time I’ve spent working on django-locality.
This seems just wrong to me. I was convinced when I was introduced to Django that it would speed up my development tenfold. Somehow, I’m a little less than impressed. Surely, I must be doing something terribly wrong here. Am I doing Django forms wrong?
I think this would make for an excellent wiki discussion.
When I ran into this problem, I used client side validation with javascript to solve the “if this selected, then make sure that is selected” problem.
As for grouping, I usually employ the multiselect widget from jquery.
For pre-filling/masking fields, use javascript; and for lookups, use ajax calls. It is a lot easier that way.
As for back end validation; I find that custom fields and validators go a long way.
django-uni-form is an elegant approach to form rendering that will clean up your templates somewhat.