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
orfields
! -
ATTENTION! To generate all model fields, use
exclude = []
orfields = '__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 ...
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'