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:
- 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>
- The user selects a position, and the
slug
is passed back into the view as aGET
parameter. - The view grabs the
Position
object from the database based on theslug
- The form is initialised, passing the
position
keyword paramater - The form sets
self.position
to save the position within theAdvertisementForm
instance, then deletes the parameter from the keyword options. - Normal form initialisation then occurs (via the
Super
call) - The position is then checked to see if the
has_title
paramater is set. If it is, then thelink_text
field is made mandatory. If it isn't, then thelink_text
field is deleted from the form. - This happens again for the
ad_text
,link_url
andimage
fields. - 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>
- When the user clicks 'Submit', the form initialisation is again run before the form is validated, using the
POST
ed '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.