Howto build Django form with Ajax and Boostrap 4

HTML forms belong among basic components of almost any website or web application. Earlier or later you as a Django developer are going to be challenged to code some Django forms mostly advance ones and because of that, you need to understand how using Django forms. In a real-life scenario, you will have to deal with validation of submitted data, displaying user feedback and error outputs, ensure basic form security and design a proper user workflow through the form with good UX. Fortunately, Django offers many libraries and tools to make form coding easier and faster.

After reading this post you are going to have a basic overview howto build a form with Django with Ajax and Boostrap technology from scratch step by step. Let’s create a good one contact form that is a mandatory part of almost every a company or landing website that you can use it as inspiration for any real-world cases.

 

Our goal – build a nice contact form with Django 3,  Ajax and Boostrap 4

In a few steps I am going to show you  how to build a nice contact form in Django from scratch without using class ModelForm.

The form will use Ajax technology for non-blocking submit processing and smooth user experience. Each form fied will be rendered by template tag because of reusability and better code clarity.  We will also use CSRF protection as template tag {% csrf_token %} for security reason. Processing of form events will be handled with the Javascript jQuery library and using CSS framework Bootstrap 4 will give us an acceptable good-looking form even though we are not graphic designers.

To successful form submiting,  you have to fill out all four fields name, e-mail, phone number, message. For more fun and study purposes I decided to meet unusual conditions. An e-mail has to arrive from the domain example.com, a phone number has to meet the phone format  +420 609 123 456 and the message have to include a word  hello,  because we are polite people, aren’t ? 🙂

For form fields we will need two types of HTML input tags text, textarea. So decided to generate them a little different way. I will use Django custom tags to simplify future changes. Check out more about Django custom template tags.

Form validation will be handled on a server-side. Each field error messages will be displayed  under an appropriate field input and a non-field error message will be shown at the top of the form. After successful submission and validation the form will be sent the info-email containing all those form fields including the message.

This will be the result of our effort. On the left picture is the look of the final form and on the right picture is the final form too but with errors displayed.

Final django contact form.

Final django contact form showing errors.

 

At the moment we have gathered all the needed information for our work. Let’s make Django forms better again.  🙂

 

Prerequisites

You have to manage basics of HTML, CSS, Python 3 and Django. You need be able to create the Python 3 virtual environment and  start the brand new Django project. You also need a functional Linux machine with the Internet connection.

 

What we are going to do

  • establish a new Python 3.8 virtual environment
  • create a structure of the brand new Django 3 project with SQLite database and configured mail server on localhost e.g Postfix
  • the file template/__default.html define the main project template
  • the  file template/form_contact.html define the form template
  • the  file templatetags/mytemplatetags.py define template tags for field rendering
  • the file forms.py define the form object for our contact form
  • the files views.py, ajax.py define view functions  for a form processing and validation including sending email functionality
  • define the jQuery code for handling form events
  • the file urls.py  defining an proper url routing

Step 1 – setup the Python virtual environment and the Django project structure for the application ContactForm

Be sure that you have installed Python 3.8 ( 3.7 is enough as well) in your Linux box in the directory /opt/python38. How to do that check out my tutorial for installing Python 3.8 to Centos Linux . Create new Django project with Python virtual environment and start Django developer server. In my case I am going to make all the work in the directory /home/hanz/projects/django-contact-form.

 

[hanz@myserver]# mkdir -p /home/hanz/projects/django-contact-form/
[hanz@myserver]# cd /home/hanz/projects/django-contact-form/

[hanz@myserver]# sudo /opt/python38/bin/python -m venv /home/hanz/projects/django-contact-form/env

[hanz@myserver]# source /home/hanz/projects/django-contact-form/env/bin/active

(env) [hanz@myserver django-contact-form]# pip install --upgrade pip
(env) [hanz@myserver django-contact-form]# pip install django

(env) [hanz@myserver django-contact-form]# python -m django --version
>>> 3.0.4

(env) [hanz@myserver django-contact-form]# django-admin startproject contactform

(env) [hanz@myserver django-contact-form]# ls -l
>>> total 8
>>> drwxr-xr-x 3 hanz hanz 4096 Feb  5 17:41 contactform
>>> drwxr-xr-x 5 hanz hanz 4096 Feb  5 17:31 env

(env) [hanz@myserver django-contact-form]# cd contactform/

(env) [hanz@myserver contactform]# python manage.py runserver
>>> Performing system checks...
>>>
>>> System check identified no issues (0 silenced).
>>>
>>> You have unapplied migrations; your app may not work properly until they are applied.
>>> Run 'python manage.py migrate' to apply them.
>>>
>>> Django version 3.0.4, using settings 'contactform.settings'
>>> Starting development server at http://127.0.0.1:8000/
>>> Quit the server with CONTROL-C.

 

Step 2 –  custom Django tags for redering  Boostrap 4 inputs markup

All the form HTML input tags we will generate with Django template tags because we want to have all the stuff on one place. Bootstrap 4 styling is much more easier and we and we also stick with the design principle DRY. We also have to take care of common HTML attributes such as label, type, required, placeholder, disabled, autocomplete, rows, cols and some helper HTML classes. Don’t forget to mark the output string as the safe with Django helper utility mark_safe(html).

 

from django.template import Library
from django.utils.safestring import mark_safe

register = Library()

@register.simple_tag
def b4_form_field(form, field_name, **kwargs):
    field_obj = form[field_name]
    field_attrs = form.fields[field_name]

    type = kwargs.get('type', 'text').lower()  # default input type is <input type="text">
    autocomplete = kwargs.get('autocomplete', 'on').lower()  # default input autocomplete is "on"

    label_for = field_obj.id_for_label
    label_text = field_obj.label
    id_ = field_obj.id_for_label
    name = field_obj.name
    value = field_obj.value if isinstance(field_obj.value, str) else ''
    autocomplete = "on" if autocomplete == 'on' else 'off'
    required = "required" if field_attrs.required else ''
    disabled = "disabled" if field_attrs.disabled else ''
    placeholder = field_attrs.widget.attrs['placeholder'] if field_attrs.widget.attrs['placeholder'] else ''
    help_text = f"<small class='form-text text-muted'>{field_obj.help_text}</small>" if field_obj.help_text else ''

    if type == 'textarea':
        text_area_rows = field_attrs.widget.attrs.get('rows', 40)
        text_area_cols = field_attrs.widget.attrs.get('cols', 5)

        html = f"""
                <div class="form-group">
                    <label for="{label_for}">{label_text}</label>
                    <textarea class="form-control" autocomplete="{autocomplete}" type="{type}" id="{id_}" name="{name}" placeholder="{value}" rows="{text_area_rows}" cols="{text_area_cols}" {disabled} {required}>{value}</textarea>
                    {help_text}
                </div>
        """
    else:
        html = f"""
                <div class="form-group">
                   <label for="{label_for}">{label_text}</label>
                   <input class="form-control" autocomplete="{autocomplete}" type="{type}" id="{id_}" name="{name}" value="{value}" placeholder="{placeholder}" {disabled} {required}>
                   {help_text}
                </div>
        """
    return mark_safe(html)

 

Step 3 – the form class definition

Django form definition the file forms.py is pretty straightforward. We create a subclass ContactForm of the forms.Form.  Purposely we don’t use the approach including class form.ModelForm  because we don’t need to save form data into a database.  After all the validation checks and cleaning, we will send all the customer data to e.g. our company service e-mail.

Take a look closely at all the clean form methods, where we handle our “weird” conditions and provide raising Validation exception or adding error message for non-field errors. For clarity I just recall that we have to accomplish that an e-mail has to arrive from the domain example.com, a phone number has to meet the phone format +420 609 123 456 and the message have to include a word hello.

 

import re
from django import forms


class ContactForm(forms.Form):
    NEEDED_WORD = "hello"
    ALLOWED_DOMAIN = "example.com"
    RE_PHONE_PATTERN = '^((\+\d{3}|\d{5}) ?)?\d{3}( |-)?\d{3}( |-)?\d{3}$'

    name = forms.CharField(label="Your name",
                           widget=forms.TextInput(attrs={"placeholder": "John Smith"}),
                           max_length=80,
                           required=True,
                           localize=False,
                           disabled=False,
                           help_text="Your name.")

    email = forms.EmailField(label="E-mail",
                             widget=forms.TextInput(attrs={"placeholder": "John.Smith@your-email.com"}),
                             max_length=80,
                             required=True,
                             localize=False,
                             disabled=False,
                             help_text="Your e-mail.")

    phone_number = forms.CharField(label="Phone number",
                                   widget=forms.TextInput(attrs={"placeholder": "+420 609 123 456"}),
                                   max_length=80,
                                   required=False,
                                   localize=False,
                                   disabled=False,
                                   help_text="Your phone number in the format +420 609 123 456.")

    message = forms.CharField(label="Message",
                              widget=forms.Textarea(attrs={'rows': 7, 'cols': 45, "placeholder": "Write your message here."}),
                              max_length=315,
                              required=True,
                              localize=False,
                              disabled=False,
                              help_text="Your message.")

    def clean_email(self):
        cleaned_data = super().clean()
        email = cleaned_data.get("email")

        if email:
            email = email.lower()
        else:
            raise forms.ValidationError("This field is required.", code="required")

        if not email.endswith(ContactForm.ALLOWED_DOMAIN):
            raise forms.ValidationError(f"The e-mail has to come from the domain \"{ContactForm.ALLOWED_DOMAIN}\".", code="invalid")

        return email

    def clean_phone_number(self):
        cleaned_data = super().clean()
        phone_number = cleaned_data.get("phone_number")

        if not phone_number:
            raise forms.ValidationError("This field is required.", code="required")

        if not re.search(ContactForm.RE_PHONE_PATTERN, phone_number):
            raise forms.ValidationError("This field has not the correct phone format \"+420 609 123 456\" or contain an invalid characters.", code="invalid")

        return phone_number

    def clean_message(self):
        cleaned_data = super().clean()
        message = cleaned_data.get("message")

        if message:
            message = message.lower()
        else:
            raise forms.ValidationError("This field is required.", code="required")

        if not ContactForm.NEEDED_WORD in message:
            raise forms.ValidationError(f"The message has to contain the word \"{ContactForm.NEEDED_WORD}\".", code="invalid")

        return message

    def clean(self):
        cleaned_data = super().clean()
        email = cleaned_data.get('email')
        message = cleaned_data.get('message')

        if email and message:
            message = message.lower()
            email = email.lower()

            # do something if both fields are valid so far.
            if not email.endswith(ContactForm.ALLOWED_DOMAIN) and not ContactForm.NEEDED_WORD in message:

                if not email.endswith(ContactForm.ALLOWED_DOMAIN):
                    self.add_error("email", f"This field e-mail has to come from the domain \"{ContactForm.ALLOWED_DOMAIN}\".")

                if not ContactForm.NEEDED_WORD in message:
                    self.add_error(f"message", f"This field message has to contain the word \"{ContactForm.NEEDED_WORD}\".")

                raise forms.ValidationError(f"The e-mail has to come from the domain \"{ContactForm.ALLOWED_DOMAIN}\" and the message has to contain the word \"{ContactForm.NEEDED_WORD}\".", code="invalid")

 

Step 4 –  the main project template

There is not something special about the main project template __default.html. It contains some CSS styles for Bootstrap 4, link to the Javascript jQuery library, an including tag for the contact form template and “thank you” message div showed after successfully form submitting.

 

<!DOCTYPE html>

<html lang="en" class="h-100">

<head>
    <link href="https://stackpath.bootstrapcdn.com/bootstrap/4.4.1/css/bootstrap.min.css" rel="stylesheet" crossorigin="anonymous">

    <script src="https://code.jquery.com/jquery-3.4.1.min.js" crossorigin="anonymous"></script>
    <script src="https://cdn.jsdelivr.net/npm/popper.js@1.16.0/dist/umd/popper.min.js" crossorigin="anonymous"></script>
    <script src="https://stackpath.bootstrapcdn.com/bootstrap/4.4.1/js/bootstrap.min.js" crossorigin="anonymous"></script>
</head>

<style>
    html, body {
        background: -webkit-linear-gradient(top, #95c8ff, #dbebfa);
        letter-spacing: 0.3px;
    }

    form ul {
        font-size: 14px;
    }

    form ul li:not(:last-child) {
        margin-bottom: 1em;
    }

    form ::placeholder {
        color: #a9b4c2 !important;
    }
</style>

<body class="h-100">

<div class="container h-100">
    <div class="row h-100 py-5 px-1 justify-content-center align-items-center">

        <div id="form-wrapper" class="col-12 col-sm-10 col-md-8 col-lg-6 pt-5 bg-light rounded-lg shadow">

            <h2 class="text-center mb-4">Contact form</h2>

            <form role="form" class="pb-3 px-4" method="post" action="" autocomplete="on" novalidate>
                {% include 'forms/form_contact.html' with form=form %}
            </form>

            <div id="message-done" class="text-center mt-5" style="display: none;">
                Thank you for your message.<br>
                I am going to answer you as soon as possible.
            </div>

        </div>

    </div>
</div>

</body>
</html>

 

Step 5 -the form template with the jQuery code

The form template form_contact.html is pretty simple too. The HTML5 form attribute novalidate ensures that the default browser validation will be disabled. We don’t need this due to our custom check on the server-side. We must not forget adding of CSRF tag {% csrf_token %} for the security reason.  All the form fields are generated by template tags as we discussed above.  The jQuery code is more complex because we have to handle data form extraction, the form AJAX submitting with JSON payload and process returned data from the server like is showing error messages and displaying “Thank you message” after success form submitting. Surely the jQuery code can be better.

 

{% load mytemplatetags %}

<form role="form" method="post" action="" novalidate>

    <div class="alert alert-danger" style="display: none;">
        <ul class="list-unstyled mb-0 py-1">
        </ul>
    </div>

    <fieldset>
        {% csrf_token %}

        {% b4_form_field form=form field_name="name" type="text" %}
        {% b4_form_field form=form field_name="email" type="text" %}
        {% b4_form_field form=form field_name="phone_number" type="text" %}
        {% b4_form_field form=form field_name="message" type="textarea" %}

        <div class="form-group">
            <button type="reset" class="btn btn-secondary mr-2">Reset</button>
            <button type="submit" class="btn btn-success">Send</button>
        </div>

    </fieldset>

</form>

<script>
    $(document).ready(function () {

        let field_error_template = $("<div class='invalid-feedback'></div>'");
        const form_wrappper = $("#form-wrapper");
        const form = form_wrappper.find('form');
        const message_done = $("#message-done");
        let non_field_alert = form.find("div.alert");

        function formRemoveError() {
            form.find(":input.is-invalid").removeClass('is-invalid');
            form.find("div.invalid-feedback").remove();
            non_field_alert.find("ul").empty();
            non_field_alert.hide();
            {#form.removeClass('was-validated');#}
        }

        function formAddFieldError(form_errors) {
           
            if ("__all__" in form_errors) {
                $.each(form_errors["__all__"], (i, value) => {
                    non_field_alert.find("ul").append($("<li>" + value["message"] + "</li>"));
                });

                non_field_alert.show();
            }

            $.each(form_errors, (field, errors) => {
                if (field !== "__all__") {

                    let input = $(':input[name=' + field + ']', form);

                    if (input) {
                        input.addClass('is-invalid');

                        $.each(errors, (i, value) => {
                            input.after(field_error_template.clone().text(value.message));
                        });
                    }
                }
            });
        }

        function formClear() {
            form.find(':input').val('');
        }

        form.on('reset', () => formRemoveError());

        $("[type='submit']", form).on('click', (event) => {
            event.preventDefault();

            const formData = new FormData(form.get(0));

            let jqxhr = $.ajax(
                {

                    url: "{% url 'ajax_contact_form' %}",
                    data: formData,

                    method: 'post',
                    dataType: 'json',
                    cache: false,
                    contentType: false, // Important!
                    processData: false, // Important!

                });

            jqxhr.done((server_data) => {

                formRemoveError();

                if (server_data.form_is_valid) {

                    form_wrappper.height(form_wrappper.height()).width(form_wrappper.width());

                    form_wrappper.fadeOut(1000, () => {
                        form.hide();
                        message_done.show();
                        formClear();
                    }).fadeIn(1000).delay(2500).fadeOut(1000, () => {
                        message_done.hide();
                        form.show();
                        form.trigger("reset");
                    }).fadeIn(1000);

                } else {
                    formAddFieldError(JSON.parse(server_data.form_errors));
                }
            });

            jqxhr.fail(() => Console.log("Error during processing form with ID: " + form.val('id'))
            )
        });
    });
</script>

 

Step 6 – the Ajax form handling in files views.py, ajax.py

The view default has only a purpose to generate the main template __default.html with a new instance of form class ContactForm. Nothing else to do.

 

from django.shortcuts import render
from web.forms import ContactForm

def default(request):
    return render(request, '__default.html', {'form': ContactForm()})

 

The view contact_form handles incoming Ajax POST requests from the form, makes the form validation and any form errors send back to the browser in JSON format then the jQuery shows an error CSS class on the appropriate form field.  The valid form data are filled into the template string and send to selected into e-mail via the Django mail sending interface  send_email(). Make sure to correctly setup your email server.

 

from django.core.mail import send_mail
from django.http import Http404, JsonResponse
from web.forms import ContactForm


def contact_form(request):
    EMAIL_TO = "info@your-company-email.com"
    EMAIL_FROM = "contact-form_no-reply@your-email.com"
    SUBJECT = "The message from a contact form"

    if not request.is_ajax():
        raise Http404('No ajax!')
    else:
        form = ContactForm(request.POST or None)

        if form.is_valid():

            message = form.cleaned_data['message']
            phone_number = form.cleaned_data['phone_number']
            email = form.cleaned_data['email']
            name = form.cleaned_data['name']

            email_content = f"""Name: {name}
Phone: {phone_number}
Email: {email}
-------------------------------------------
{message}
"""

            send_mail(SUBJECT, email_content, EMAIL_FROM, [EMAIL_TO])
            # print(email_content)

            r = {'form_is_valid': True}
        else:
            r = {'form_is_valid': False, 'form_errors': form.errors.as_json()}

    return JsonResponse(r)

 

Step 7 – the URL routing settings

In the URL Django dispatcher, we only take care of two URL paths / and /ajax/contact-form/ with correct mapping to views. Take a note at the URL naming that we use throughout the project.

 

from django.urls import path

from web import views as web_views
from web import ajax as web_ajax

urlpatterns = [
    path('', web_views.default, name='default'),
    path('ajax/contact-form/', web_ajax.contact_form, name='ajax_contact_form'),
]

 

Step 8 –  the form is alive

Here is the result of our great efforts. The Django contact form works. Good job all.

 

Django contact form in action.


Final thoughts

Congratulations!
You’ve reached the end!

At this point, you have some basic insight about creating a form in Django with Ajax from scratch.

I hope you enjoyed this guideline and you learned something new. If you have some tips for improvements or found a mistake, give me a shout in comments.

Enjoy!
Hanz

2 thoughts on “Howto build Django form with Ajax and Boostrap 4”

  1. Great tutorial for creating objects through ajax. Thans for sharing. I have a question, How would you handle edit an object with this method?

    • I assume you mean the inline form editing, but it is not solved in my example.

Comments are closed.