Fluent in Django: Improving your application

Fluent in Django: Improving your application

Uploading an image, using packages, pagination, customizing admin

This is the second part of Fluent in Django series. We're going to upgrade the code we created in Part 1. You can find the finished code from the first part here.

Prerequisites

  • Python
  • Basics of Django (Creating a project, Model, View, Templates, Forms)
  • Basic understanding of HTML, CSS, and Bootstrap
  • Finished project from Part 1

If you don't want to do go through the first part, you'll need to get the project we created in Part 1 running.

How to get the poject from Part 1 running:

  • clone the repo
  • create a virtual enviorenment
  • run pip install -r requirements.txt
  • run python manage.py migrate
  • run python manage.py createsuperuser
  • start the server with python manage.py runserver
  • go to 127.0.0.1:8000/admin and create 4 books - two with a past date and two in the future.

If you're struggling with getting the project running, I encourage you to start with Part 1.

What are you going to learn

  • You'll create an additional field for your form that will allow users to upload their photo when posting their opinion.
  • Your BookCLub page could look a little better - that's why you're going to add some custom CSS (don't worry, the emphasis is not on CSS but on connecting it with Django).
  • Why do it by yourself, if someone else has already done it - you'll use two Django packages to make your page better.
  • You'll learn how to tailor Django admin to your needs.

Uploading an image

To enable users to be more personal, you'll add a possibility to upload a photo of them reading the book they're commenting on. To do this, you'll need a directory that will store the uploaded files, and connect it with Django.

Create a directory called media in the root directory. Open settings.py and add MEDIA_ROOT and MEDIA_URL.

#settings.py

MEDIA_ROOT = BASE_DIR.joinpath('media/')
MEDIA_URL = '/media/'

MEDIA_ROOT is the absolute path to the directory where the files uploaded by users will be held. MEDIA_URL is the url from where the images will be served.

To be able to serve user-uploaded media files from MEDIA_ROOT, you need to use django.conf.urls.static in your urls.py file.

# tutorial/urls.py
from django.conf.urls.static import static
from tutorial import settings

urlpatterns = [
    path('admin/', admin.site.urls),
    path('', include('bookclub.urls')),
]

if settings.DEBUG:
    urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)

This method is inefficient and insecure and is only suitable for development. That's why we only add the static url pattern if DEBUG is set to TRUE in your settings.py. For the production, you'll need to do things differently. We'll cover pushing to production in the latter part of the series.

Django has two model fields that allow user uploads - FileField and ImageField. ImageField is a specialized version of FileField that uses Pillow to confirm that a file is an image. Pillow is an image processing library for Python, build on the unmaintained Python Imaging Library (PIL).

Install Pillow with:

(env)$ python -m pip install Pillow

Add image field to you model:

class Discussion(models.Model):
    book = models.ForeignKey('bookclub.Book', related_name='discussion', on_delete=models.CASCADE)
    author = models.ForeignKey('auth.User', related_name='records', on_delete=models.CASCADE)
    opinion = models.TextField()
    image = models.ImageField(upload_to='images', blank=True)

The image field has to contain the information where the images will get stored (media/images). We also added a blank=True, which means that the image is not a required field.

Migrate the changes:

(env)$ python manage.py makemigrations
(env)$ python manage.py migrate

You need to add an enctype attribute to the form if you want the file to be uploaded correctly:

<!--bookclub/templates/book_detail.html-->

<form method="post" enctype="multipart/form-data"> <!--this is new-->
    {% csrf_token %}
    {{ discussion_form.as_table }}
    <br>
    <button type="submit" class="btn btn-primary mt-2">Submit</button>
</form>

enctype has to be set to multipart/form-data to allow the <input>element to upload file data.

To display the image in the discussion card, you'll have to rearrange the html tags and bootstrap classes, but the basic idea stays the same:

<!--bookclub/templates/book_detail.html-->

<!--...-->
<div class="container"> <!-- this is also new -->
            <div class="row"> <!-- this is also new -->
                {% for opinion in book.discussion.all %}

                    <div class="card mb-3" style="width: 500px;">
                        <div class="row no-gutters">
                            <div class="col-md-4">
                                {% if opinion.image %}
                                    <img src="{{ opinion.image.url }}" class="card-img">
                                {% else %}
                                    <img src="http://www.jennybeaumont.com/wp-content/uploads/2015/03/placeholder.gif" class="card-img">
                                {% endif %}
                            </div>
                            <div class="col-md-8">
                                <div class="card-body">
                                    <h5 class="card-title">{{ opinion.author }}</h5>
                                    <p class="card-text">{{ opinion.opinion }}</p>
                                </div>
                            </div>
                        </div>
                    </div>
                {% empty %}
                    <div class="alert alert-secondary mt-4" role="alert">
                        There is no opinions yet for this book.
                    </div>
                {% endfor %}
            </div> <!-- this is also new -->
        </div> <!-- this is also new -->
<!--...-->

You can access the uploaded images url with {{ opinion.image.url }}. Because the image is optional (blank=True in our model), you need to check if the image exists with {% if opinion.image %}. Otherwise, you provide a placeholder.

Adding custom CSS

Things look fine but they could look much better. Since this is not a CSS tutorial, we're going to change just a few small things, just enough for you to learn how to add static files.

At the bottom of the settings.py file, you can see the static url defined: STATIC_URL = '/static/'. This tells Django the first part of url your static files can be found.

As with MEDIA_URL, this is not suitable for production.

Create a new folder named static inside the bookclub folder.

BookClub
└─── bookclub
     └─── static

Django will automatically load static files from any folder named static.

MEDIA_URL and STATIC_URL must have different values. So do MEDIA_ROOT and STATIC_ROOT.

To avoid the collision of two files with the same name inside different apps, organize it the same way as templates - inside the static folder create a folder with the same name as your app.

Create a static folder inside the bookclub app. Inside it, create another folder, named bookclub. To make it even more organized, create another folder, called css. You want to keep your code neatly organized, so you'll be able to navigate through it even when the project grows big. Inside the css folder, create a file style.css.

So this is what you have so far:

BookClub
└─── bookclub
     └─── static
         └─── bookclub
              └─── css
                   └─── style.css

Now open the style.css and add the following code:

/*bookclub.css*/

body {
    padding-bottom: 50px;
    background-color: whitesmoke;
}

.card.mb-3:nth-child(even) {
    margin-left: 20px
}

.card-img {
    position: absolute;
    top: 50%;
    transform: translateY(-50%);
}

form {
    margin-bottom: 50px;
}

This doesn't work yet - you have to connect the css file with the html. Open base.html and add the tag to load static filed and link to your css file inside the <head>.

{% load static %} <!--this is new -->
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>BookClub</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@4.6.0/dist/css/bootstrap.min.css" integrity="sha384-B0vP5xmATw1+K9KRQjQERJvTumQW0nPEzvF6L/Z6nronJ3oUOFUFpCjEUQouq2+l" crossorigin="anonymous">
<link rel="stylesheet" href="{% static '/bookclub/css/bookclub.css' %}"> <!--this is new -->
</head>

You're using the static template tag to build the url for the given relative path ({% static '/css/bookclub.css' %}), but before using it, you need to import it ({% load static %}).

This looks a little better:

with_css.png

If you like CSS, feel free to add a little more pizzaz to your BookClub.

Adding Django packages

Why write it by yourself if someone else already put a lot of time to create the thing you need? There are quite some packages for Django that can make your life easier. You're going to use two of them:

  • Crispy forms to make the opinion form prettier
  • CKEditor so the book description and opinions can be more than just a plain text

When including Django packages, be careful. Always check on GitHub that the package is well maintained (good: updated in last 3 months / bare minimum: updated in the last year) and that users are content with it (widely used package: at least 1000 star / marginal package: 250 stars). When it comes to the packages, be like Coco Chanel - less is more.

Crispy forms

django-crispy-forms lets you control the rendering behavior of your Django forms in a very elegant and DRY way. You can make your forms prettier by adding just two simple things to the template.

Install the crispy forms in your shell:

(env)$ pip install django-crispy-forms

Add it to INSTALLED_APPS:

INSTALLED_APPS = [
    # ...
    'crispy_forms',
]

Each Django-related package you install, you need to add to INSTALLED_APPS if you want to use it.

Open book_detail.html and switch the {{ discussion_form.as_table }} part for {{ discussion_form|crispy }}. For this to work, you need to load crispy_forms_tags prior to that, so the code looks like that:

<!--book_detail.html-->
{% if discussion_open %}

{% load crispy_forms_tags %} <!-- this is new -->

<b>What are your thoughts on the book?</b>
{% if user.is_authenticated %}
    <form method="post" enctype="multipart/form-data">
        {% csrf_token %}
        {{ discussion_form|crispy }} <!-- this is changed -->
        <br>
        <button type="submit" class="btn btn-primary mt-2">Submit</button>
    </form>
{% else %}
    <div class="alert alert-warning">Only registered users are allowed to post their opinion.</div>
{% endif %}

Refresh the page and the form should look way prettier:

crispy_form.png

CKEditor

Django CKEditor is a package, that adds a WYSIWYG (What-You-See-Is-What-You-Get) editor to your admin fields and your forms.

Install the CKEditor in your shell:

(env)$ pip install django-ckeditor

Add it to INSTALLED_APPS:

INSTALLED_APPS = [
    # ...
    'crispy_forms',
    'ckeditor',
]

Now you have to copy static CKEditor required media resources into the directory given by the STATIC_ROOT setting. You don't have STATIC_ROOT set yet.

In the bottom of the settings.py, near the STATIC_URL, add

#settings.py

STATIC_ROOT = BASE_DIR.joinpath('staticfiles')

Now run the following command in your shell:

(env)$ python manage.py collectstatic

Check the folders in your project. There's an additional folder now, called staticfiles that contains all the static files that exist in your project.

Open models.py and change the description field in the Book class:

# models.py 
from ckeditor.fields import RichTextField

class Book(models.Model):
# ...
description = RichTextField()
# ...

The description is now a RichTextField. RichTextField behaves like a standard TextField, except it includes possibilities to edit the text. Don't forget to import it. Migrate the changes with python manage.py makemigrations and python manage.py migrate.

Open one of the books at http://127.0.0.1:8000/admin/bookclub/book/ and check it out.

rich_field.png

Play around a little and edit the text. If you want the rich text to display properly, you need to add one more thing to the template:

<!--book_detail.html-->

<div class="card">
    <div class="card-body">
        {{ book.description|safe}} <!-- this is changed -->

        {% if not discussion_open %}
            <div class="alert alert-primary mt-4" role="alert">
                Read this book by: {{book.read_by}}
            </div>
        {% endif %}
    </div>
</div>

|safe Marks a string as not requiring further HTML escaping prior to output, thus making it render correctly.

Open the book you edited in the browser and now your book description has a little more style:

rich_field_rendered.png

Users are still not able to use the CKEditor in the form.

Change the TextField() to a RichTextField() for opinion filed on the Discussion class, as you did for the Book.

# models.py 
from ckeditor.fields import RichTextField

class Discussion(models.Model):
    book = models.ForeignKey('bookclub.Book', related_name='discussion', on_delete=models.CASCADE)
    author = models.ForeignKey('auth.User', related_name='records', on_delete=models.CASCADE)
    opinion = RichTextField() # this is changed
    image = models.ImageField(upload_to='images/', blank=True)

Migrate the change with python manage.py makemigrations and python manage.py migrate. For the form to show the correct field, you just need to make sure all form media is present. Add {{ discussion_form.media }} to the book_detail.html prior to the {{ discussion_form|crispy }}.

<!--book_detail.html-->

<form method="post" enctype="multipart/form-data">
    {% csrf_token %}
    {{ discussion_form.media }} <!-- this is changed -->
    {{ discussion_form|crispy }}
    <br>
    <button type="submit" class="btn btn-primary mt-2">Submit</button>
</form>

Refresh the page and there it is.

ck_form.png

You have to add |safe for the opinions in the book_detail.html to render correctly.

<!--book_detail.html-->
{% for opinion in book.discussion.all %}
    <div class="card mb-3" style="width: 500px;">
        <div class="row no-gutters">
            <div class="col-md-4">
                {% if opinion.image %}
                    <img src="{{ opinion.image.url }}" class="card-img">
                {% else %}
                    <img src="http://www.jennybeaumont.com/wp-content/uploads/2015/03/placeholder.gif" class="card-img">
                {% endif %}
            </div>
            <div class="col-md-8">
                <div class="card-body">
                    <h5 class="card-title">{{ opinion.author }}</h5>
                    <p class="card-text">{{ opinion.opinion|safe }}</p> <!-- this is changed -->
                </div>
            </div>
        </div>
    </div>
{% empty %}
    <div class="alert alert-secondary mt-4" role="alert">
        There is no opinions yet for this book.
    </div>
{% endfor %}

Don't forget the all_books.html. If you go to http://127.0.0.1:8000/, you'll see that text has escaped HTML. As on the book detail page, you need to add |safe - in three places.

For the showcased book:

<!--all_books.html-->
<div class="jumbotron">
    <p class="lead">What were your thoughts on the latest book?</p>
    <h3>{{previous_books.0.book}}</h3>
    <hr class="my-4">
    <p>{{previous_books.0.description|truncatewords:150|safe}}</p> <!-- |safe added -->
    <a class="btn btn-primary btn-lg" href="{% url 'book_detail' pk=previous_books.0.pk %}" role="button">See the discussion</a>
</div>

For the upcoming books:

<!--all_books.html-->
{% for upcoming_book in upcoming_books %}
    <a href="{% url 'book_detail' pk=upcoming_book.pk %}" class="list-group-item list-group-item-action">
        <div class="d-flex w-100 justify-content-between">
            <h5 class="mb-1">
                {{ upcoming_book.book }}
            </h5>
            <small class="text-muted">
                Read by: {{ upcoming_book.read_by }}
            </small>
        </div>
        <p class="mb-1">
            {{ upcoming_book.description|safe }} <!-- |safe added -->
        </p>
    </a>
{% endfor %}

And for the previous books:

<!--all_books.html-->

{% for previous_book in previous_books %}
    <a href="{% url 'book_detail' pk=previous_book.pk %}" class="list-group-item list-group-item-action">
        <div class="d-flex w-100 justify-content-between">
            <h5 class="mb-1">
                {{ previous_book.book }}
            </h5>
        </div>
        <p class="mb-1 center">
            {{ previous_book.description|safe }} <!-- |safe added -->
        </p>
    </a>
{% endfor %}

Refresh and everything look good.

all_books_ck.png

Pagination

Currently, the user can only see the last 3 previous books and the first three upcoming books. But what if they want to see other books? Maybe they're looking for an idea of what to read next. Provide them with an option to browse through all the books.

Create an url 'book-list':

# bookclub/urls.py

urlpatterns = [
    path('', views.all_books, name="all_books"),
    path('book-list', views.book_list, name="book_list"), # this is new
    path('book/<int:pk>/', views.book_detail, name="book_detail"),
]

Now for the view:

# bookclub/views.py

from django.core.paginator import Paginator


def book_list(request):
    books = Book.objects.all()
    paginator = Paginator(books, 5)

    page_number = request.GET.get('page')
    page_books = paginator.get_page(page_number)

    return render(request, 'bookclub/book_list.html', {'page_books': page_books})
  • You retrieve all the Book instances, as you did at the beginning of Part 1.
  • You break them into groups of five with Paginator(books, 5).
  • You get the info about on which page the user is from the url with request.GET.get('page').
  • You retrieve the books on this page from the Paginator - paginator.get_page(page_number).
  • You render only the books from that page to the template

Just dumping them in the template is quite easy, but combining them with bootstrap can look complicated. Inside the templates/bookclub directory, create a book_list.html.

<!--book_list.html-->

{% extends 'bookclub/base.html' %}

{% block title %}
    All the books
{% endblock %}}

{% block content %}
    <div class="list-group">
        {% for book in page_books %}
        <a href="{% url 'book_detail' pk=book.pk %}" class="list-group-item list-group-item-action mt-4">
            <div class="d-flex w-100 justify-content-between">
                <h5 class="mb-1">
                    {{ book.book }}
                </h5>
            </div>
            <p class="mb-1">
                {{ book.description|safe }}
            </p>
        </a>
        {% endfor %}
    </div>

    <nav class="mt-3">
        <ul class="pagination justify-content-center">
            {% for page in page_books.paginator.page_range %}
            <li class="page-item {% if page == page_books.number %}active{% endif %}">
                <a class="page-link" href="?page={{ page }}">
                    {{ page }}
                </a>
            </li>
            {% endfor %}
        </ul>
    </nav>

    <a href="{% url 'all_books'%}" class="float-right"> Home</a>
{% endblock %}

The first part is more-or-less copy-pasted from the all_books template, only you're looping through page_books.

As for the part inside the <nav>:

All the html is taken from Bootstrap. The home link takes the user to the main page.

With Django, you loop through this:

page_books.paginator.page_range

page_books is the queryset you passed from the view. The paginator in the template is not the same as the one you created in the view. They have the same name, but you could name it differently in the book_list and the paginator in the template would still work (I encourage you to try that, it's always good to see it with your own eyes). page_range is a range iterator of page numbers ([1, 2, 3, 4]), so the {{ page }} each time shows one of the numbers on the list. If you'd just like to get the total number of pages, you can use num_pages.

The only other Django thing her is:

{% if page == page_books.number %}active{% endif %}

With this, you check if the current page iteration (page) is the same as the current page you're on (page_books.number). If it is, the Bootstrap class active is added.

pagination.png

Add the link to this page at the bottom of the home page (inside the content block), so you can navigate to the list easily:

<!--all_books.html-->

<a href="{% url 'book_list' %}" class="btn btn-primary mt-3">Check all books</a>

Changing the admin

Admin panel can be very useful. But if you have to open the Book object (3) for the seventh time to check if that's the book you're looking for is not user-friendly. This is Django, of course, there's a better way.

Django's admin has many hooks for customization. Here you'll use only a few, but if you need something else, you have the documentation for it here.

Open admin.py and create a ModelAdmin with all the customization:

# ...
class BookAdmin(admin.ModelAdmin):
    list_display = ('book', 'read_by')
    ordering = ('-read_by',)
    search_fields = ['book']
    fields = (('book', 'read_by'), 'description')

admin.site.register(Book, BookAdmin)

# ...

So, BookAdmin is basically a 'list' of all the changes you want for your admin.

  1. list_display determines which fields get displayed in the list of objects
  2. ordering determines the default field to order objects by when the page loads.
  3. search_fields determines which fields are searchable
  4. fields are meant to make simple layout changes. In this case, we force the book and the read_by fields in the same line. This is the only change we made to the single-file admin view

changed_admin.png

change_admin_single.png

Now it will be much easier to find and edit the correct book.

Conclusion

In this part, you made a huge leap from the Django basics. You added an image field, you used external packages, added custom CSS, learned how to do pagination, and changed admin to your liking. All of the things we added have a lot more to show, so I invite you to experiment a little. Play around with the packages, customize admin a little more and if you're not sure what's going on with the code, try to rename or comment out something. That way, you understand why things are the way they are in no time.

You can find the whole code from Part 2 on GitLab.

Continue with the Part 3 - COMING SOON, where we'll get the know the Django login, so your members will be able to register to the site.

Did you find this article valuable?

Support GirlThatLovesToCode by becoming a sponsor. Any amount is appreciated!