Tuesday, July 9, 2013

Forms in Django

Django 1.4, JQuery 1.7.2, django-crispy-forms
Want to get information from a user? News flash, you need a form :)
In this post I will show how to create a form that will have its own page, another form that won't have its own page, and finally how to add more than one form to a page.

To Each its Own

Every form starts in forms.py, where you define what fields it will have, in other words what information you want from a user. Form is a kind of a pipe between a model, a database, and a user. So, to create a form in Django you first of all need a model.
In models.py:
class SuggestedWebSite(models.Model):

    homepage_url = models.URLField(_("homepage url"))
    picture = models.ImageField(_("picture"), upload_to='user_suggested_websites', blank=True, null=True)
    added_at = models.DateTimeField(_("added at"), default=datetime.now)
    added_by = models.ForeignKey(User, related_name="suggested_websites", verbose_name=_("added by"))

    class Meta:
        verbose_name = _("suggested site")
        verbose_name_plural = _("suggested sites")

    def __unicode__(self):
        d = extract(self.homepage_url)
        return d.domain + '.' + d.tld
I use translation, cos this site shows in Hebrew and the arguments that look like  _("str") used for translating fields names. About picture field and how to upload files in Django I will talk later on, in another post.
Now to forms.py:
from django.forms import ModelForm
from myproject.websites.models import SuggestedWebSite

class SuggestedWebSiteForm(ModelForm):

  class Meta:
    model = SuggestedWebSite
    fields = ('homepage_url', 'picture')
and of course views.py:
from myproject.websites.forms import SuggestedWebSiteForm
from myproject.websites.models import SuggestedWebSite
from crispy_forms.helpers import FormHelper
from django.contrib.auth.decorators import login_required
from django.core.urlresolvers import reverse
from django.http import HttpResponseRedirect
from django.core.mail import mail_managers

def abs_url(request, url):
    site = RequestSite(request)
    return "http://%s%s" % (site.domain, url)

def suggest_website(request):
    if request.method == 'POST':
        form = SuggestedWebSiteForm(request.POST, request.FILES)
        if form.is_valid():
            form.instance.added_by = request.user

            mail_managers(_('User suggested a site: %s') % 
                             form.cleaned_data['homepage_url'], "\n".join([ 
                             _('url: %s') % form.cleaned_data['homepage_url'],
                            abs_url(request, reverse('admin:websites_suggestedwebsite_change', args=(form.instance.id,))),
                            abs_url(request, reverse('admin:websites_suggestedwebsite_changelist')),]))
            return HttpResponseRedirect(reverse('suggest_thankyou'))
        form = SuggestedWebSiteForm()

    form.helper = FormHelper()
    form.helper.form_tag = False

    return render(request, 'suggest_website.html', {'form': form, })
Lets take a look at what is going on here. First of all, see the decorator @login_required, in other words, only logged in users will be able to access this page. This small decorator checks if request.user.is_authenticated() before allowing the user to post, if you don't use it, and you don't want everyone to be able to submit the form, then you will need add this check before posting anything to your server. Now, the user is authenticated, follows the link, fills in the form, clicks on submit and the data is sent to the server. On server side we now check if the method is post, which means we are about to write data to server. Now we create a new instance of this form, and check that the user entered valid data, more on that you can find on Django website, if the data was found valid I do a few more things with the data, save the form, inform relevant people about this event and then redirect a user to a nice page, that says thank you. I the form wasn't sent in post method, I create an unbound form, and present it with all its errors, nothing gets to be written to the server in this case. form.helper that you see is a part of crispy-forms, that includes some very nice functionality for handling forms in Django.
in urls.py:
from django.conf.urls import patterns, url
from django.conf import settings
from django.views.generic.simple import direct_to_template

urlpatterns = patterns('',
    url(r'^suggest/$', 'buggy.websites.views.suggest_website', name='suggest_website'),
    url(r'^suggest-thankyou/$', direct_to_template,  {'template': 'suggest_thankyou.html'}, name='suggest_thankyou'),
and of course a template to output it all nice and easy:
{% extends "base.html" %}
{% load i18n %}
{% load crispy_forms_tags %}

{% block title %}Suggest a Website{% endblock %}

{% block content %}

<form enctype="multipart/form-data" method="post">
 {% csrf_token %}
 {% crispy form %}
 <input type="submit" value="{% blocktrans %}Submit{% endblocktrans %}" class="btn" />

{% endblock %}
Notice enctype attribute, it's a part of what it takes to allow file uploads. csrf_token is a must for every post method you make, to protect you from cross site attacks. crispy form outputs the form nice and easy, and if you have validation errors, like submitting null form, it will be outputted nice and friendly to the user, explaining the problem at hand and suggesting how to fix it.
Very nice and easy :)

 Template Crisys - How to Add a Form to Another Page

So, what if I don't want to create separate template to output the form?
Have no fear, here it comes :)
Where it all begins, in models.py of course:
from django.utils.translation import ugettext_lazy as _
from datetime import datetime
from django.contrib.auth.models import User
from django.db import models

class WebSiteComment(models.Model):

    content = models.TextField(_("content"))
    picture = models.ImageField(_("picture"), blank=True, null=True,  upload_to='upics', max_length=200)
    site = models.ForeignKey(WebSite, related_name="comments", verbose_name=_("site"))
    added_by = models.ForeignKey(User, verbose_name=_("added by"))
    added_at = models.DateTimeField(_("added at"), default=datetime.now)
    approved = models.BooleanField(_("approved"), default=False)
    approved_by = models.ForeignKey(User, blank=True, null=True, related_name="sitecomments_approved", verbose_name=_("approved by"))

    class Meta:
        verbose_name = _("site comment")
        verbose_name_plural = _("site comments")

    def __unicode__(self):
        return "Comment by %s on %s" % (self.added_by, self.site)
In forms.py:
from django.forms import ModelForm
from myproject.websites.models import WebSiteComment

class AddSiteCommentForm(ModelForm):
    class Meta:
        model = WebSiteComment
        fields = ('content', 'picture')
Very simple and easy, from all the fields the user will see only the content and picture fields, with their translated names.
Where it all connects, views.py:
from myproject.websites.forms import AddSiteCommentForm
from myproject.websites.models import WebSite, WebSiteComment
from crispy_forms.helpers import FormHelper
from django.core.urlresolvers import reverse
from django.shortcuts import render
from annoying.decorators import  JsonResponse
from django.http import HttpResponseRedirect

def details(request, dname):
    w = WebSite.objects.get(domain=dname)

    comment_form = None

    if request.user.is_authenticated():
        if request.method == 'POST':
            comment = WebSiteComment(site=w, added_by=request.user, added_at=datetime.datetime.now())
            comment_form = AddSiteCommentForm(request.POST, request.FILES, instance=comment)
            if comment_form.is_valid():
                return HttpResponseRedirect(reverse('myproject.websites.views.details', args=(dname,)))

            comment_form = AddSiteCommentForm()

        comment_form.helper = FormHelper()
        comment_form.helper.form_tag = False

    issues = [(o, o.is_affecting_user(request.user) if request.user.is_authenticated() else False) for o in w.issues.all()]

    favourite = False
    if request.user.is_authenticated():
        if UserFavouriteWebsite.objects.filter(user=request.user, site=w).exists():
            favourite = True

    return render(request, 'details.html', {
                                            'website': w,
                                            'is_favourite': favourite,
                                            'comment_form': comment_form,
Now this is a very nice example, you can see here quit a few things. As you can see, no login decorator here, so I'm performing the check for authentication myself. And, as before, I make sure that post method was used correctly, and here is first difference from before. First I create a comment instance, then I create a new form instance and connect them to each other, basing new form instance on comment instance, filling in all the fields that were not presented to the user, and filled in automatically on server side. Secondly, if the form was handled with no errors, then i reload the page, presenting new comments added, and showing the form again, if the user will want to add some more comments. And then I continue working on other elements that will be shown on the page, and return all the data for rendering.
Meanwhile in the template:
<div class="span6">

 <a name="comments" href="#"></a>
 <h2>{% trans "Comments" %}:</h2>
 {% for c in website.comments.all %}
 <div id="id-comments" class="well">
  <h3>{{ c.added_by }}</h3>
  <p>{{ c.content }}</p>
  {% if c.picture %}
   <img src={{c.picture.url}} />
  {% endif %}
 {% endfor %}

 <div class="well">
 {% if user.is_authenticated %}
  <form id="comment-form" enctype="multipart/form-data" method="post">
   {% csrf_token %}
   {% crispy comment_form %}
   <input type="submit" value="{{ submit }}" class="btn"/>

 {% else %}
 <a href="{% url django.contrib.auth.views.login %}?next={{ request.path }}%23comments">{% trans "Log in to post a comment" %}</a>
 {% endif %}
This is just a part of the template that outputs the comments. Other things happen all around.

Forms Party! - the more the merrier :)

So far we always handled one form per page, per view, but what happens when you need to handle more than one form? How to do it? Well, the main idea is to add different names to the forms, kind of hooks in our forms, to make it easy for the server to catch each one in turn.
No drastic changes in models.py and forms.py, except for having more models and more forms. So, lets take a look at where it will really change.
In a template:
<form id="comment-form" enctype="multipart/form-data" method="post">
 {% csrf_token %}
 {% crispy comment_form %}
 <input type="hidden" name="formtype" value="comment"/>
 <input type="submit" value="{{ submit }}" class="btn"/>

<form id="issue-form" enctype="multipart/form-data" method="post">
 {% csrf_token %}
 {% crispy issue_form %}
 <input type="hidden" name="formtype" value="issue"/>
 <input type="submit" value="{{ submit }}" class="btn"/>
And in views.py:
issue_form = None
comment_form = None

if request.method == 'POST':
   if request.POST['formtype'] == 'issue':
      issue = Issue(site=w, added_by=request.user, added_at=datetime.datetime.now())
      issue_form = AddIssueForm(request.POST, request.FILES, instance=issue, prefix="issue")
      if issue_form.is_valid():
         return HttpResponseRedirect(reverse('myproject.websites.views.details', args=(dname,)))
   elif request.POST['formtype'] == 'comment':
       comment = WebSiteComment(site=w, added_by=request.user, added_at=datetime.datetime.now())
       comment_form = AddSiteCommentForm(request.POST, request.FILES, instance=comment, prefix="comment")
       if comment_form.is_valid():
          return HttpResponseRedirect(reverse('myproject.websites.views.details', args=(dname,)))
As you can see, all the trick was to add another hidden input tag in a template, to pass specific name connected to that form, catch it on the server side and do what you need to do with them.

No comments:

Post a Comment