Skip to content

Forms

Theory

Django has an abstraction for us to represent web forms. Thanks to it, we will be able to conveniently prepare and handle the form representation in Python. The form will help us manage, for example:

  • The kinds of form fields displayed to the user

  • Building accurate HTML for the form

  • Server-side validation

Presentation

Forms inherit from the Form class. We define fields in them using similar classes as for model fields definitions, but defined in the django.forms module. By convention, form fields (and model fields) have the suffix Field (e.g. CharField).

One of the form field parameters is the so-called widget that represents the control displayed to the user. Each field has a defined default widget, which, however, can always be overwritten in the field constructor by specifying the named parameter widget. The value of the widget parameter can be class or instance of the widget. Based on the different widgets, different HTML code is generated on the page to the specified control.

Default widgets are usually input elements with a different type. This type guarantees the implementation of a specific validation and to a limited extent extends the functionality of an ordinary text-box. In case of input[@type=number] (default widget IntegerField) the text-box does not accept values other than numbers and a minus sign at the beginning, you can define ranges of values, arrows appear to help you set the value using the cursor, and events of pressing up-down keys to modify the value are handled.

First, let's create a movie form:

from django.forms import (
  CharField, DateField, Form, IntegerField, ModelChoiceField, Textarea
)

from viewer.models import Genre


class MovieForm(Form):
  title = CharField(max_length=128)
  genre = ModelChoiceField(queryset=Genre.objects)
  rating = IntegerField(min_value=1, max_value=10)
  released = DateField()
  description = CharField(widget=Textarea, required=False)

We set the title field to the maximum length of 128 characters. The genre field will be a check-box of all the values in the Genre table. The rating field will not be able to receive a value outside the 1-10 range. The description field, thanks to the widget override, will be able to be multi-line (as opposed to the single-line title and the default field widget CharField; input[@type=text] will be replaced with an item textarea). The description field will not be required.

Forms can be used in function views, but let's skip this step and go straight to CBV ;)

from django.views.generic import FormView, ListView

from viewer.forms import MovieForm
from viewer.models import Movie


class MovieCreateView(FormView):
  template_name = 'form.html'
  form_class = MovieForm

We use a special view class FormView to template the forms. In the view, we indicate which form we want to use by setting the form_class field.

In the form.html template, it is enough to put the form variable to render the form. The template engine will call the __str__ method on it, which will generate the HTML for the form as a table field.

{% extends "base.html" %}

{% block content %}
  <form method="post">
    {% csrf_token %}
    <table>{{ form }}</table>
    <input type="submit" value="Submit" class="btn btn-primary">
  </form>
{% endblock %}

There is a tag above the tag that generates the form controls {% csrf_token %} used to confirm the origin of data from a form in order to prevent attacks CSRF.

Don't forget to register the new view in urls.py!

from django.contrib import admin
from django.urls import path

from viewer.models import Genre, Movie
from viewer.views import MovieCreateView, MoviesView

admin.site.register(Genre)
admin.site.register(Movie)

urlpatterns = [
  path('admin/', admin.site.urls),
  path('', MoviesView.as_view(), name='index'),
  path('movie/create', MovieCreateView.as_view(), name='movie_create')
]

CSRF

Cross-Site Request Forgery it is a method of attacking websites. It is an attack method consisting in executing queries accepted by website forms. These queries are built by scripts placed by the attacker on foreign servers. The attack uses an authorized session of a user with certain privileges to use the user's browser to perform an action that the attacker is not entitled to. It is enough for the attacker to convince the authorized user to open a link to the page with the script executing the query. Contrary to appearances, this is not a difficult task - it is enough to build a real-sounding address that will be clicked by an unsuspecting user without reflecting on its origin. After this type of attack, no trace is left - the change introduced in the state of the website was made by an authorized user session, who actually has certain rights. The user sending the link remains anonymous.

To protect yourself from attack: - Generate the token when creating the form on the back-end and save it on the server - WThe generated token should be placed in the form on the page as an element input[@type=hidden] - The token will be sent back together with the data from the form by the browser after the form is approved - The token received with the form should be verified on the back-end

Only after such verification the form can be processed.

Of course, we don't have to worry about all of these operations as this is fully automated by Django. All we have to do is put a tag {% csrf_token %} in the form template.

Building a "double" query by a foreign domain is secured by the CORS mechanism, so it is impossible to execute a GET query to obtain a token on another domain and use it to execute the query using the POST method.

To avoid attacks from your own domain (e.g. when a user with low privileges wants to use a session of a user with higher privileges and enters the script in the data added to the database), the website must be protected against XSS attacks by securing data from the user against execution via a web browser. By default, Django doesn't trust data from any template variables and protects them against browser interpretation. Remember, however, that whenever we mark code as safe, the data from the user must be placed in the tag {% autoescape on %} or pass through a filter escape. These are critical rules for the health and safety of our applications!


Cleanup and validation

We should check the correctness of the data provided in the forms. We can perform validation by:

  • Definition of the validation function

    • We use this method when validation is independent of the field type
  • Overriding the validation function in the field class

    • We use this method when the validation is related to the field type
  • The method clean_ <field_name> of the form

    • We use this method when validation is related to a field in the context of the form
  • clean method of the form

    • We use this method when validation requires checking more than one field

In the forms, we should clean up the data provided. We can cleanup by:

  • Overriding the cleanup function in the field class

    • We use this method when cleaning is related to the field type
  • The method clean_ <field_name> of the form

    • We use this method when cleaning is related to a field in the context of a form
  • clean method of the form

    • We use this method when cleaning requires more than one field to be processed

Let's take a look at an example of the following validation and data cleansing techniques:

import re
from datetime import date

from django.core.exceptions import ValidationError
from django.forms import (
  CharField, DateField, Form, IntegerField, ModelChoiceField, Textarea
)

from viewer.models import Genre


def capitalized_validator(value):
  if value[0].islower():
    raise ValidationError('Value must be capitalized.')


class PastMonthField(DateField):

  def validate(self, value):
    super().validate(value)
    if value >= date.today():
      raise ValidationError('Only past dates allowed here.')

  def clean(self, value):
    result = super().clean(value)
    return date(year=result.year, month=result.month, day=1)


class MovieForm(Form):

  title = CharField(max_length=128, validators=[capitalized_validator])
  genre = ModelChoiceField(queryset=Genre.objects)
  rating = IntegerField(min_value=1, max_value=10)
  released = PastMonthField()
  description = CharField(widget=Textarea, required=False)

  def clean_description(self):
    # Force each sentence of the description to be capitalized.
    initial = self.cleaned_data['description']
    sentences = re.sub(r'\s*\.\s*', '.', initial).split('.')
    return '. '.join(sentence.capitalize() for sentence in sentences)

  def clean(self):
    result = super().clean()
    if result['genre'].name == 'commedy' and result['rating'] > 5:
      self.add_error('genre' '')
      self.add_error('rating' '')
      raise ValidationError(
        "Commedies aren't so good to be rated over 5."
      )
    return result

Data handling

We should handle the correct data in the view. To do this, you need to reload the method form_valid. In the form_valid method, we should create a model object based on the data from the form, and then save it to the database. When the data fails validation, the form_invalid method is run. In the form_invalid method, we can, for example, log information about failed validation.

from logging import getLogger

from django.urls import reverse_lazy
from django.views.generic import FormView, ListView

from viewer.forms import MovieForm
from viewer.models import Movie

LOGGER = getLogger()


class MovieCreateView(FormView):

  template_name = 'form.html'
  form_class = MovieForm
  success_url = reverse_lazy('movie_create')

  def form_valid(self, form):
    result = super().form_valid(form)
    cleaned_data = form.cleaned_data
    Movie.objects.create(
      title=cleaned_data['title'],
      genre=cleaned_data['genre'],
      rating=cleaned_data['rating'],
      released=cleaned_data['released'],
        description=cleaned_data['description']
    )
    return result

  def form_invalid(self, form):
    LOGGER.warning('User provided invalid data.')
    return super().form_invalid(form)

ModelForm

The handling of the model in the form can be simplified by using the ModelForm class. In the form, we define the subclass Meta. Based on the model indicated in the subclass, default form fields will be generated based on the model fields. We can use the fields or exclude fields to indicate a list of fields to display or not to display

  • ATTENTION! Exactly one of the fields is required exclude or fields!

  • ATTENTION! To generate all model fields, use exclude = [] or fields = '__all__'!

In the form class, we can override the fields that we want to work differently than the default. We can also add our own fields. We can leave the fields that keep their default behavior without definition.

class MovieForm(ModelForm):

  class Meta:
    model = Movie
    fields = '__all__'

  title = CharField(validators=[capitalized_validator])
  rating = IntegerField(min_value=1, max_value=10)
  released = PastMonthField()

  def clean_description(self):
    # Force each sentence of the description to be capitalized.
    initial = self.cleaned_data['description']
    sentences = re.sub(r'\s*\.\s*', '.', initial).split('.')
    cleaned = '. '.join(sentence.capitalize() for sentence in sentences)
    self.cleaned_data['description'] = cleaned

  def clean(self):
    result = super().clean()
    if result['genre'].name == 'commedy' and result['rating'] > 5:
      raise ValidationError(
        "Commedies aren't so good to be rated over 5."
      )
    return result

CreateView

By using the CreateView view, you can automate the creation of a model entry in the database. We can omit the implementation of the form_valid method, because its default implementation adds a record to the database based on the form fields of the ModelForm type. We need to define success_url, which will redirect the user to another page after the correct form approval.

from logging import getLogger

from django.urls import reverse_lazy
from django.views.generic import CreateView, ListView

from viewer.forms import MovieForm
from viewer.models import Movie

LOGGER = getLogger()


class MovieCreateView(CreateView):

  template_name = 'form.html'
  form_class = MovieForm
  success_url = reverse_lazy('movie_create')

  def form_invalid(self, form):
    LOGGER.warning('User provided invalid data.')
    return super().form_invalid(form)

CRUD

We use four operations to manage data. Their acronym is CRUD:

  • CREATE – creating new data

  • READ – data reading

    • Access to a single record

    • Access to the list of records

    • A strictly defined record list reading

  • UPDATE – editing existing data

    • Editing the entire record

    • Editing some record features

  • DELETE – deletion of existing data

CUD operations change the state of the database. To approve CUD operations in forms, we use the HTTP POST method.

UpdateView

We use the same form when updating. We need to provide the model in the view, and in urls.py - the main key argument of the record.

from logging import getLogger

from django.urls import reverse_lazy
from django.views.generic import ListView, UpdateView

from viewer.forms import MovieForm
from viewer.models import Movie

LOGGER = getLogger()


class MovieUpdateView(UpdateView):

  template_name = 'form.html'
  model = Movie
  form_class = MovieForm
  success_url = reverse_lazy('index')

  def form_invalid(self, form):
    LOGGER.warning('User provided invalid data while updating a movie.')
    return super().form_invalid(form)
from django.contrib import admin
from django.urls import path

from viewer.models import Genre, Movie
from viewer.views import MovieCreateView, MoviesView, MovieUpdateView

admin.site.register(Genre)
admin.site.register(Movie)

urlpatterns = [
  path('admin/', admin.site.urls),
  path('', MoviesView.as_view(), name='index'),
  path('movie/create', MovieCreateView.as_view(), name='movie_create'),
  path('movie/update/<pk>', MovieUpdateView.as_view(), name='movie_update')
]

DeleteView

DeleteView does not use the form object. Although we need to write the form using HTML in the template

class MovieDeleteView(DeleteView):
  template_name = 'movie_confirm_delete.html'
  model = Movie
  success_url = reverse_lazy('index')
{% extends "base.html" %}

{% block content %}
  <form method="post">
    {% csrf_token %} 
    <p>
      Are you sure you want to delete movie "{{ object.title }}" from database?
    </p> 
    <input type="submit" value="Submit" class="btn btn-danger">
  </form> 
{% endblock %}
from django.contrib import admin
from django.urls import path

from viewer.models import Genre, Movie
from viewer.views import (
  MovieCreateView, MovieDeleteView, MoviesView, MovieUpdateView
)

admin.site.register(Genre) 
admin.site.register(Movie)

urlpatterns = [
  path('admin/', admin.site.urls),
  path('', MoviesView.as_view(), name='index'),
  path('movie/create', MovieCreateView.as_view(), name='movie_create'),
  path('movie/update/<pk>', MovieUpdateView.as_view(), name='movie_update'),
  path('movie/delete/<pk>', MovieDeleteView.as_view(), name='movie_delete')
]

Administrator panel VS CRUD views

All the described CRUD operations are implemented by the administrator panel. However, it should not be used as an interface for CRUD operations for the end user! It is only reserved for technical system administrators for several reasons:

  • The administrator panel is built entirely for each model by default

  • The widgets in the administrator panel are not necessarily convenient

  • Changing the administrator panel is more difficult

  • You can spend more time defining what you don't want from the administrator panel than creating your own forms

Own layout

The .as_table method is run when the form is displayed on the page. Thanks to this, the form is displayed in the form of a table. Other ways to view the form are:

  • {{ form.as_table }}

  • {{ form.as_p }}

  • {{ form.as_ul }}

In the course of our work on the project, the business requirements changed and we were asked to introduce a new layout to the movie form ...

new_layout

To overwrite the layout of the form, you should "break" the HTML code into individual elements (widgets, errors, labels, etc.) and write the whole thing exactly in the HTML file.

{% extends "base.html" %}

{% block content %}
  <form method="post">
    {% csrf_token %}
    {{ form.non_field_errors }}
    <div class="form-group">
      {{ form.title.errors }}
      <label for="{{ form.title.id_for_label }}">Title:</label>
      {{ form.title }}
    </div>
    <div class="row">
      <div class="col">
        <div class="form-group">
          {{ form.genre.errors }}
          <label for="{{ form.genre.id_for_label }}">Genre:</label>
          {{ form.genre }}
        </div>
      </div>
      <div class="col">
        <div class="form-group">
          {{ form.rating.errors }}
          <label for="{{ form.rating.id_for_label }}">Rating:</label>
          {{ form.rating }}
        </div>
      </div>
      <div class="col">
        <div class="form-group">
          {{ form.released.errors }}
          <label for="{{ form.released.id_for_label }}">Released:</label>
          {{ form.released }}
        </div>
      </div>
    </div>
    <div class="form-group">
      {{ form.description.errors }}
      <label for="{{ form.description.id_for_label }}">Description:</label>
      {{ form.description }}
    </div>
    <input type="submit" value="Submit" class="btn btn-primary">
  </form>
{% endblock %}

Additionally, in order to add the css class to the widget code, the value in the dictionary should be overwritten in the form class.

class MovieForm(ModelForm):

  def __init__(self, *args, **kwargs):
    super().__init__(*args, **kwargs)
    for visible in self.visible_fields():
      visible.field.widget.attrs['class'] = 'form-control'