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
andSTATIC_URL
must have different values. So doMEDIA_ROOT
andSTATIC_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:
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:
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.
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:
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.
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.
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.
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.
list_display
determines which fields get displayed in the list of objectsordering
determines the default field to order objects by when the page loads.search_fields
determines which fields are searchablefields
are meant to make simple layout changes. In this case, we force thebook
and theread_by
fields in the same line. This is the only change we made to the single-file admin view
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.