Monday, April 5, 2010

More Django Form Hacking

Inline Formsets:

The activities in the Kukui Cup competition are fairly complex objects. They're complicated enough that the basic Django form validations will not work without a fair bit of tweaking. Some of the fields in an activity are either optional or required depending on the values of other fields. Here's a few things we need outside of the basic form processing:
  1. If an activity is an event (is_event = True), then it must have an event date.
  2. If the confirmation type is either "confirmation code" or "image upload", then a prompt is required. Examples would be "Enter the confirmation code you received at the event" or "Upload a photo of yourself holding a CFL and an incandescent light bulb".
  3. If the confirmation type is "question and answer", then at least one question and answer is required.
  4. Publication date must be before the expiration date.
1, 2, and 4 are pretty straightforward, especially since I had already taken care of 1. Here's the new activity admin form.

class ActivityAdminForm(ModelForm):
class Meta:
model = Activity

def clean(self):
# Data that has passed validation.
cleaned_data = self.cleaned_data

#1 Check that an event has an event date.
is_event = cleaned_data.get("is_event")
event_date = cleaned_data.get("event_date")
has_date = cleaned_data.has_key("event_date") #Check if this is in the data dict.

if is_event and has_date and not event_date:
self._errors["event_date"] = ErrorList([u"Events require an event date."])
del cleaned_data["is_event"]
del cleaned_data["event_date"]

#2 Check the verification type.
confirm_type = cleaned_data.get("confirm_type")
prompt = cleaned_data.get("confirm_prompt")
if confirm_type != "text" and len(prompt) == 0:
self._errors["confirm_prompt"] = ErrorList([u"This confirmation type requires a confirmation prompt."])
del cleaned_data["confirm_type"]
del cleaned_data["confirm_prompt"]

#4 Publication date must be before the expiration date.
if cleaned_data.has_key("pub_date") and cleaned_data.has_key("expire_date"):
pub_date = cleaned_data.get("pub_date")
expire_date = cleaned_data.get("expire_date")

if pub_date >= expire_date:
self._errors["expire_date"] = ErrorList([u"The expiration date must be after the pub date."])
del cleaned_data["expire_date"]

return cleaned_data
Number 3 is a little tricky, because there can be one or many question and answer pairs. I was already aware of inline forms from the Django tutorial. So the first step was to add in inline forms for the questions and answers.

Question and answer fields in the admin form.

Easy enough, but now I have to implement the validation. These questions and answers should only be provided if the confirm type is "text". I did some research and figured out that I needed to extend the BaseInlineFormSet class to provide my custom validation behavior.

class TextQuestionInlineFormSet(BaseInlineFormSet):
"""Custom formset model to override validation."""

def clean(self):
"""Validates the form data and checks if the activity confirmation type is text."""

# Form that represents the activity.
activity_form = self.instance

# Count the number of questions.
count = 0
for form in self.forms:
if form.cleaned_data:
count += 1
except AttributeError:

if activity_form.confirm_type == "text" and count == 0:
raise ValidationError("At least one question is required if the activity's confirmation type is text.")

elif activity_form.confirm_type != "text" and count > 0:
raise ValidationError("Questions are not required for this confirmation type.")

class TextQuestionInline(admin.StackedInline):
model = TextPromptQuestion
extra = 3
formset = TextQuestionInlineFormSet
I chose to raise the validation error if the confirm type is not text because I don't want any question and answers saved for activities that do not need it. This should take care of most of the activity admin interface, but the requirements can always change.


There are other students working on my Kukui Cup implementation. Their goal is to redesign the interface through the use of HTML and CSS. We don't want just one redesign; we want them to attempt many redesigns so that we can evaluate each one. So, instead of having them hack the file to change themes, I added a drop down form at the top so that they can change the CSS files easily. This also required moving the original files into folders to create a "default" theme. I also created custom template tags so that any file that ends with ".css" is imported in the header.

def render_css_import():
"""Renders the CSS import header statements for a template."""

return_string = ""
css_dir = os.path.join(settings.PROJECT_ROOT, "media", settings.KUKUI_CSS_THEME, "css")
if os.path.isdir(css_dir):
items = (item for item in os.listdir(css_dir) if string.find(item, "css") >= 0)
for item in items:
return_string += "<link rel=\"stylesheet\" href=\"/site_media/static/" + settings.KUKUI_CSS_THEME
return_string += "/css/" + item + "\" />\n"

return return_string

This should make it easy for the other students to see how the interface changes as they develop their own CSS.

No comments:

Post a Comment