×

Search anything:

PDF Library application in Django [WebDev Project]

Binary Tree book by OpenGenus

Open-Source Internship opportunity by OpenGenus for programmers. Apply now.

BookStacks Screenshot

Introduction

The only way to learn a new programming language is by writing programs in it.

That is the opening clause for the Getting Started section of Chapter 1 of the book - The C programming Language By Brian W. Kernighan and Dennis M. Ritchie.

When it's coming from the inventor of C, and given his venerability in the computer science field, It's sufficient to communicate the importance of practice when learning. For aspiring Django developers, I come bearing good news - BookStacks.

In this article at OpenGenus, we have developed a Full-Stack Django application that allows users to download, read, and upload books. In the receding sections, we shall explore how the functionality was setup to meet the requirements of the application with the hope of hightening your interest in extending the application's functionality.

Download source code

The source code can be downloaded from github.com/OpenGenus/PDF-hosting

Create a virtual environment

Using python venv module

python -m venv pdf_library

The above command creates a virtual environment with the name 'pdf_library'. A folder with the name 'pdf_library' is created in the current working directory. This only works for python >= 3.3

Using virtualenv

virtualenv --python=/usr/bin/python3 pdf_library

Application structure

pdf_library/
├─ db.sqlite3
├─ acccounts/
│  ├─ admin.py
│  ├─ apps.py
│  ├─ forms.py
│  ├─ __init__.py
│  ├─ managers.py
│  ├─ models.py
│  ├─ tests.py
│  ├─ urls.py
│  ├─ views.py
│  ├─ jinja2/
├─ templates/
│  ├─ base/
│  ├─ ui_components/
├─ library/
│  ├─ admin.py
│  ├─ api_views.py
│  ├─ apps.py
│  ├─ fields.py
│  ├─ forms.py
│  ├─ __init__.py
│  ├─ mixin.py
│  ├─ models.py
│  ├─ serializers.py
│  ├─ signals.py
│  ├─ tests.py
│  ├─ urls.py
│  ├─ utils.py
│  ├─ validators.py
│  ├─ views.py
│  ├─ jinja2/
│  │  ├─ book_category_list.html
│  │  ├─ book_edit.html
│  │  ├─ book_list.html
├─ config/
│  ├─ asgi.py
│  ├─ development.ini
│  ├─ __init__.py
│  ├─ jinja2.py
│  ├─ production.env
│  ├─ settings.py
│  ├─ urls.py
│  ├─ wsgi.py
├─ mange.py
├─ requirements.txt

You don't have to fret, this will all become clear at the end of this article at OpenGenus.

Configuration

All the project configurations are kept under the config directory. Django gives this directory a name similar to the one of the project name - pdf_library at the time of project creation. However giving this directory a semantic name - config is better since this name succintly communicates the directory's purpose.

from pathlib import Path
from environ import Env

env = Env()

BASE_DIR = Path(__file__).resolve().parent.parent
CONFIG_DIR = Path(__file__).resolve().parent

DEBUG = True
if DEBUG is True:
    env.read_env(CONFIG_DIR / "development.env")
else:
    env.read_env(CONFIG_DIR / "production.env")

SECRET_KEY = env.str("SECRET_KEY")

ALLOWED_HOSTS = env.list("ALLOWED_HOSTS")
...
THIRD_PARTY_APPS = [
    "rest_framework",
    "crispy_forms",
    "crispy_bootstrap4",
]
LOCAL_APPS = [
    "accounts",
    "library",
]
INSTALLED_APPS += THIRD_PARTY_APPS + LOCAL_APPS

AUTH_USER_MODEL = "accounts.User"
...

TEMPLATES = [
    {
        'BACKEND': 'django.template.backends.jinja2.Jinja2',
        'DIRS': [BASE_DIR / "templates"],
        'APP_DIRS': True,
        'OPTIONS': {
            "environment": "config.jinja2.environment"
        },
    },
    {
        'BACKEND': 'django.template.backends.django.DjangoTemplates',
        'DIRS': [],
        'APP_DIRS': True,
        'OPTIONS': {
            'context_processors': [
                'django.template.context_processors.debug',
                'django.template.context_processors.request',
                'django.contrib.auth.context_processors.auth',
                'django.contrib.messages.context_processors.messages',
            ],
        },
    },
]

It is common for an application's settings to change as it is pushed from a development to a prodcution environment and during that process, lots of changes to configuration files are made. To keep the changes to a minimum, we use environment files to independently define variables that are subject to change during transition from a developmenet to a production environment. Besides storing volatile data, they can also be used to store secret information. If they are used to store secret information, it is important to keep from them out of version control systems.

The DEBUG variable signals transition to/from either environments. After reading the appropriate environment file, we define the applications that constitute the project. The applications have been categorized into local and third party applications, however this isn't a prerequisite but a practice to improve readability. And with that in mind, it is inevitable to distinguish between local and third party applications. Local apps are those whose functionality you will have to write by yourself, and whereas third party applications are those from external sources like PyPI(Python Packaging Index) and these tend to address recurring problems so that you don't have to reinvent the wheel.

The project defines two(2) local apps(accounts and library) and three(3) third party applications rest_framework, crispy_forms, crispy_bootstrap4.

Before applying any migrations, it's advisable to define a custom user model even when the default user model is just sufficient for your project. It's a pessimistic approach but a justifiable one for projects that have frequently changing requirements. The AUTH_USER_MODEL variable points to the custom user model of the project - accounts.User (The User class defined under the accounts application in models.py)

The templates configuration defines two template engines - Jinja2 and django. The project will use the former, however, we still need to retain the Django templating engine because some Django applications like django admin site and some third party applications rely on it for rendering templates.

Jinja2 Environment

import jinja2
from django.templatetags.static import static
from django.urls import reverse
from crispy_forms.templatetags.crispy_forms_filters import as_crispy_form

from library.forms import BookUploadForm
from library.utils import to_mbs


class Environment:

    def __call__(self, **options):
        env = jinja2.Environment(**options)
        env.globals.update({
            "static": static,
            "url": reverse,
            "crispy": as_crispy_form,
            "book_upload_form": BookUploadForm(),
            "to_mbs": to_mbs,
        })
        return env


environment = Environment()

Jinja2 supports both a global environment apporach and context processors for exposing common(across requests) context variables in templates. However the context processor approach is discouraged because Jinja2 supports calling functions with arguments in templates which isn't the case when using the Django Templating Language(DTL).

You can either use a function or class to define the global environment. When using the class approach, you have to implement the __call__() dunder method as shown above so that instances of this class can be invoked just like functions.

A few notable functions whose functionality we can't do without are static and reverse. static is used to include static files(images, css, js) in templates and the reverse function is used to resolve route names to actual urls.

It's worthy noting that all variables included in the environment will be accessed in templates via their respective keys.

URLs

from django.contrib import admin
from django.urls import path, include
from django.conf import settings
from django.conf.urls.static import static
from django.views.generic import RedirectView


urlpatterns = [
    path("", RedirectView.as_view(url="library/books"), name="home"),
    path('admin/', admin.site.urls),
    path("accounts/", include("accounts.urls")),
    path("library/", include("library.urls"))
]

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

The urls.py in the config directory is where you consolidate the access points for the different applications that constitute your project. We basically define the base urls for accessing the different applications. And as for our case, accessing accounts/ will imply accessing the accounts application and likewise for the library/, will imply accessing the library application.

In order to use the Django administration site, we have to include the django admin urls, and these have been associated to the admin/ url, though you can choose to use a different base url.

Lastly, we allow the django development server to serve static files when debugging is enabled. In a production setting, you would be serving media files on a different web server like Apache, Ngnix e.t.c.

Applications

It is important to split the project into manageable applications with each application handling a separate functionality. This eases the debugging process (locating errors), localizes changes to only a single application hence improving maintainability.

Library

In terms of functionality, this application will handle book uploads, listing books, searching books, deleting books, and DMCA (Digital Millenium Copyright Act) violations.

We shall keep the functionality to a minimum and only focus on listing and uploading books. Some views have been intentionally left undefined or partially defined in order for you to have some practice.

Models

BookAuthor

class BookAuthor(models.Model):
    name = models.CharField(max_length=150)

    def __str__(self):
        return self.name

BookCategory

class BookCategory(models.Model):
    name = models.CharField(max_length=150, unique=True)
    description = models.TextField(null=True, blank=True)

    def __str__(self):
        return self.name

    class Meta:
        verbose_name_plural = "Book categories"

Book

User = get_user_model()


def book_file_upload_path(instance, filename):
    return "books/{}/{}".format(slugify(instance.title), filename)


def book_cover_image_upload_path(instance, filename):
    return "books/{}/{}".format(slugify(instance.title), filename)

class Book(models.Model):
    title = models.CharField(max_length=150, unique=True)
    num_of_pages = models.IntegerField(editable=False)
    file_size = models.FloatField(editable=False)
    cover_image = models.ImageField(upload_to=book_cover_image_upload_path, editable=False)
    file = models.FileField(upload_to=book_file_upload_path, validators=(PDFRequired(), NonMaliciousPDFRequired()))
    categories = models.ManyToManyField("BookCategory", related_name="books")
    authors = models.ManyToManyField("BookAuthor", related_name="books")
    uploader = models.ForeignKey(User, on_delete=models.CASCADE, related_name="books", null=True, editable=False)

    @property
    def download_url(self):
        return self.file.url

    @property
    def cover_image_url(self):
        return self.cover_image.url

    def is_uploader(self, user):
        return self.uploader == user

    def __str__(self):
        return self.title

The categories and authors field have been defined as ManyToManyField because a book can be written by more than one author, and an author can write more than one book. Likewise for categories, a book can belong to so many categories and a category can be associated to more than one book.

The fields whose editable attribute has been set to True are those fields we don't want the web application administrator(or any user) to edit as their values are inferred from the uploaded book with the help of signals as we shall see later.

As you might have noticed, the uploader field is allowed to be null, and subsequently, the application shall permit uploads from anonymous users.

To adhere to the principle of least knowledge(Law of Demeter), we define accessor methods that behave as properties in order to easily access object properties without having to traverse a heirachy of objects to get to the desired property i.e book.file.url. This keeps us from entrusting so much undesired knowledge of our models in our templates.

Signals

import io
import fitz
from django.dispatch import receiver
from django.db.models.signals import pre_save
from django.core.files.base import ContentFile
from django.template.defaultfilters import slugify

from .models import Book

@receiver(pre_save, sender=Book)
def attach_book_meta_data(sender, instance, *args, **kwargs):
    with fitz.Document(stream=io.BytesIO(instance.file.read())) as pdf:
        instance.file.seek(0)
        instance.file_size = instance.file.size
        instance.num_of_pages = pdf.page_count
        cover_image_file = ContentFile(pdf.load_page(0).get_pixmap().tobytes(),
                                       name="{}_cover.png".format(slugify(instance.title)))
        instance.cover_image = cover_image_file
        instance.file.seek(0)  # restore file cursor

In the code above, We are simply registering a synchronous or blocking callback when a pre save signal for the Book model is emitted. This callback relies on PyMuPDF package whose discussion is beyond the scope of this article. Because we are listening for a pre save signal, the book(specifically, the file is of interest here) isn't saved yet or uploaded to the configured upload directory. Consequently, the file contents can either be in memory or a temporary file i.e in /tmp depending on the FILE_UPLOAD_MAX_MEMORY_SIZE setting. Due to this uncetainity, we delegate reading of the file to Django by calling the read() method on the file field.

In the callback's body, we determine the file size, page count, and cover image and assign them to their respective fields.

Views

BookListView

class BookListView(ListView):
    model = Book
    template_name = "library/book_list.html"
    context_object_name = "books"
    paginate_by = settings.MY_MAX_ITEMS_PER_PAGE

The BookListView inherits from the ListView which provides a basis for views that need to present a list of objects to a user. Class based views significantly improve maintainability, reduce boilerplate code. It's one of the many places in Django in which the DRY(Don't Repeat Yourself) principle manifests.

For any ListView to work, you have to define the model attribute or get_queryset() function and the template_name attribute as the minimum requirement. The paginate_by controls how many objects a page should contain and the context_object_name is there to improve readability in our template. If context_object_name isn't defined, the context variable will conform to the format <model_name>_list i.e book_list.

Uncle Bob advises against disinformation in variable naming. The word list communicates a different meaning across programmers. Besides that, the value of the variable might not be a list thus making a programmer alledgely believe that the variable value implements the list interface. Relating this to our class based view, book_list is actually not a list but a queryset(Queryset) that happens to partially implement the list interface.

NOTE: Under the hood, Class based views behave as function based views. The principle that forms the basis for this kind of implementation is the first class citizen nature of python functions.

BookUploadView

class BookUploadView(SuccessMessageMixin, CreateView):
    template_name = "library/book_list.html"  # Used incase the form is invalid
    extra_context = {
        "books": Book.objects.all()
    }
    http_method_names = ["post"]
    form_class = BookUploadForm
    success_url = reverse_lazy("home")
    success_message = "Book uploaded successfully"

    @transaction.atomic
    def post(self, request, *args, **kwargs):
        return super().post(request, *args, **kwargs)

    def form_valid(self, form):
        return super().form_valid(form)

    def form_invalid(self, form):
        # Overwrite the global variable 'book_upload_form'(unbound global form) so that template receives the erroneous form and not the unbound global form (book_upload_form)
        return self.render_to_response(self.get_context_data(book_upload_form=form))

A CreateView requires us to define a success_url attribute or get_sucess_url() function and a model or form_class or get_form_class() function and a template_name attribute. When the form is valid, the user is redirected to the success_url. And when the form is invalid(erroneous), the response(invalid form) is rendered in the template defined by template_name.

In the preceding code snippet, we are intercepting the form_invalid() in order to provide an informative context variable name to the invalid form. I personally think this is doing so much for just changing the context variable name of the form. Hopefully, this will change in later Django versions.

The form_class points to BookUploadForm which is the form used for saving the book to the database.

The transaction.atomic decorator on the post() method turns off auto commiting(just for this route) of database queries especially create, update and delete queries. Incase an exception occurs with in the view, the transaction is rolled back. Its like you didn't do any create, update, delete queries. However, it's important to know that, middleware and template rendering runs outside of the view and thus exceptions during these processes willnot cause the transaction to roll back.

BookUploadForm

from django import forms

from .models import Book, BookAuthor, BookCategory
from .fields import Select2TaggedMultipleChoiceField

class BookUploadForm(forms.ModelForm):
    authors = Select2TaggedMultipleChoiceField(BookAuthor, create_authors,
                                               widget=forms.SelectMultiple(attrs={"class": "authors-select"}))
    categories = Select2TaggedMultipleChoiceField(BookCategory, create_categories,
                                                  widget=forms.SelectMultiple(attrs={"class": "categories-select"}))

    class Meta:
        model = Book
        fields = ("title", "authors", "categories", "file")

This form is based on the ModelForm class which allows us to quickly and elegantly create forms from an existing model, and instantly save form data to a database. One use case scenario that best fits the purpose of a model form is when data collected in a form will be saved in the database.

The authors and categories fields have been redefined to use a custom defined field - Select2TaggedMultipleChoiceField - looked at later. For a small project, the default MultipleChoiceField would suffice but when the number of authors and categories becomes so large, it will result in a bad user experience. You don't want to have your user choose from a list of 300 authors, it's just tedious. A common technique is to populate the select field with options based on what the user types. To our luck, there is a javascript library just for this - select2.

The other requirement that Select2TaggedMultipleChoiceField addresses is that of dynamic creation of authors or categories. This is known as "tagging" in the select2 language. Categories and authors are very diverse attributes that can span a range of values, and these values may differ in wording from person to person most especially for the categories. Subsequently, all the authors and book categories can never be known beforehand.

In order for the authors and categories fields to integrate harmoniously with the select2 library, we have to tweak the default MultipleChoiceField a little to fit our use case.

Select2TaggedMultipleChoiceField
class Select2TaggedMultipleChoiceField(forms.MultipleChoiceField):
    """
    The essence of using a select2 select control is to defer the loading of the choices and provide them as the user
    types in the control, Thus this field shouldn't raise errors for non-existent choices but creates them
    """

    def __init__(self, model, auto_choices_create_fn, *args, **kwargs):
        self.model = model  # Used to find choices that don't exist yet in the database
        self.auto_choices_create_fn = auto_choices_create_fn  # Used to create non-existent choices
        kwargs["choices"] = ()  # Turn off choices
        super().__init__(*args, **kwargs)

    def validate(self, value):
        if self.required and not value:
            raise forms.ValidationError(self.error_messages["required"], code="required")

    def clean(self, value):
        value = self.to_python(value)
        self.validate(value)
        self.run_validators(value)

        non_available_choices = list(filter(lambda v, s=self: s.castable_to_int(v) is False, value))
        possible_available_choices = list(filter(lambda v, s=self: s.castable_to_int(v), value))
        # Filter out any user choices that are not in the database but appear in possible choices(bad input from user)
        available_choices = list(
            map(lambda v: v[0], self.model.objects.filter(pk__in=possible_available_choices).values_list("id")))
        available_choices.extend(self.auto_choices_create_fn(non_available_choices))
        return available_choices

    @staticmethod
    def castable_to_int(value):
        try:
            int(value)
        except ValueError:
            return False
        return True

The constructor defines two additional parameters, model and auto_choices_create_fn. The model parameter is used to validate existing choices that they actually exist in the database. The auto_choices_create_fn is a callable that creates the missing choices and returns a list of IDs of the created choices. Why not dynamically create options from with in the field class, this would expose the structure of the model with in the field class i.e what model attributes should be set before saving the non existing choices.

The line kwargs["choices"] = () disables prepopulating the select field with any options on rendering thus ensuring an empty select field.

It is important to note that when tagging is turned on in select2, the value of a dynamically created option is verbatim(as is) and doesn't correspond to any choice in the database.

The default MultipleChoiceField raises a ValidationError for choices that don't exist in the database, which isn't a desired behaviour for our case. This kind of validation is done in the clean() method.

def clean(value):
...
    non_available_choices = list(filter(lambda v, s=self: s.castable_to_int(v) is False, value))
    possible_available_choices = list(filter(lambda v, s=self: s.castable_to_int(v), value))
    # Filter out any user choices that are not in the database but appear in possible choices(bad input from user)
    available_choices = list(
        map(lambda v: v[0], self.model.objects.filter(pk__in=possible_available_choices).values_list("id")))
    available_choices.extend(self.auto_choices_create_fn(non_available_choices))
    return available_choices

The default implementation of a MultipleChoiceField expects a list of valid primary keys, and the assumption taken here is, the primary keys are represented as integers at the database level. Basing on this assumption(which is true for this project), we treat any value that can't be cast to an integer as a missing choice. To further filter out dirty values that may pose as integers and not really existing in the database, we check with the database.

The auto_choices_create_fn is invoked with non existing choices, after which, we concatenate the returned IDs to the existing choice IDs to have a complete list of valid primary keys(IDs) for the selected choices.

Templates

To keep a clean and organised application structure, each application has a jinja2 and static directories that keep the application's templates and static files respectively. Certain elements on a page should persist across page navigations i.e site header, footer, and when you have such elements, it's nice to have a template that defines these persisting elements as well as content placeholders for child templates to populate. The templates/base/base.html is the suitable place to accomplish this.

{% import "ui_components/ui_components.html" as ui_components %}
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>{% block title %} BookStacks | Download books in all formats for free {% endblock title %}</title>
    <!-- Albert Sans Google Font -->
    <link rel="preconnect" href="https://fonts.googleapis.com">
    <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
    <link href="https://fonts.googleapis.com/css2?family=Albert+Sans&display=swap" rel="stylesheet">

    <!-- Jquery -->
    <script src="{{ static('jquery/js/jquery.min.js') }}"></script>

    <!-- Bootstrap -->
    <link rel="stylesheet" href="{{ static('bootstrap/css/bootstrap.min.css') }}">
    <script src="{{ static('bootstrap/js/bootstrap.bundle.min.js') }}"></script>

    <!-- Select2 -->
    <link rel="stylesheet" href="{{ static('select2/css/select2.min.css') }}">
    <script src="{{ static('select2/js/select2.min.js') }}"></script>

    <!-- Base styles -->
    <link rel="stylesheet" href="{{ static('base/css/base.css') }}">
    <script src="{{ static('base/js/base.js') }}"></script>
    <link rel="stylesheet" href="{{ static('base/css/helpers.css') }}">

    <!-- UI components css -->
    <link rel="stylesheet" href="{{ static('ui_components/css/ui_components.css') }}">

    {% block stylesheets %}{% endblock stylesheets %}
    {% block scripts %}{% endblock scripts %}
</head>
<body>
<div class="book-upload-form-modal modal fade" id="book-upload-form-modal" role="dialog">
    <div class="modal-dialog">
        <div class="modal-content">
            <div class="modal-header">
                <h3 class="modal-title">Upload book</h3>
                <button type="button" class="close" data-dismiss="modal">
                    <span>&times;</span>
                </button>
            </div>
            <div class="modal-body">
                <form method="post" action="{{ url('book-upload') }}" enctype="multipart/form-data" id="book-upload-form">
                    {{ csrf_input }}
                    {{ crispy(book_upload_form) }}
                </form>
            </div>
            <div class="modal-footer">
                <button type="button" class="btn btn-sm btn-outline-secondary" data-dismiss="modal">Close</button>
                <button type="submit" class="btn btn-sm btn-success book-upload-form-submit">Upload</button>
            </div>
        </div>
    </div>
</div>
<!-- End of upload form -->

<div class="top-bar">
    {{ ui_components.SiteHeader(request.user) }}
</div>

<div class="main-content-container container">
    {{ ui_components.Navigation(request.path_info) }}
    {% block content %}{% endblock content %}
</div>

<footer>
    <a class="attribution" target="_blank" href="https://icons8.com/icon/MmkqIRv7P6Xy/books-emoji">Books Emoji</a> icon by <a target="_blank" href="https://icons8.com">Icons8</a>
</footer>

{# Show the book-upload-form-modal when the form has errors #}
{% if book_upload_form.errors %}
<script>
    $("#book-upload-form-modal").modal({show: true});
</script>
{% endif %}
</body>

Frontend frameworks like Angular, React, Vue and among others have simplified, systemized and modularized frontend development through reshaping the developer's perspective of the user interface. Rather than looking at it at a template level(collection of HTML elements), it is integral to look at user interface at an organism(atomic design) level. A user interface is a collection of organisms(components) that each perform a separate function.

For one to identify components that make up an application's user interface, it's fundermental to visualize the user interface in terms of functionality, and how certain functionality is frequently being reused across pages. However, this isn't a single page application (SPA) and perhaps we can't use any of the above mentioned frontend frameworks without rewriting the application, but this doesn't limit us from improvising with the tools at our disposal to come up with a strategy that could yield similar benefits.

Though not that great, components have been defined using jinja2 macros in a single file - templates/ui_components/ui_components.html. For simplicity we shall treat macros synonymously to functions. Knowledge of HTML, CSS and Jinja2 macros is a prerequisite to understanding the working of the components, however this is out of scope and we shall only focus more on what they do than how they do it.

Book

{# Book #}
{% macro Book(book) -%}
<div class="book">
    <div style="background-image: url('{{ book.cover_image_url }}')" class="book__cover-image"></div>
    <div class="book__info">
        <h2 class="book__title text-truncate">{{ book.title }}</h2>
        <span class="book__page-count h-secondary-text">{{ book.num_of_pages }} Pages</span>
        <span class="book__file-size h-secondary-text">{{ to_mbs(book.file_size) }} MB</span>
        <span class="book__uploader h-secondary-text">Uploaded by: {% if book.uploader %} {{ book.uploader }} {% else %}
            Anonymous {% endif %}</span>
    </div>

BookList

{# Book List #}
{% macro BookList(books) -%}
<div class="book-list">
    {% for book in  books %}
        {{ Book(book) }}
    {% endfor %}
</div>
{%- endmacro %}

Search Form

{# Search Form #}
{% macro SearchForm() -%}
<form class="search-form" method="get" action="search">
    <div class="search-form__field">
        <input class="search-form__input form-control" type="text" placeholder="Search books..." required="required">
    </div>
    <button class="search-form__submit btn" type="submit">GO</button>
</form>
{%- endmacro %}
{% macro Navigation(active_url) -%}
<div class="navigation tabs is-medium">
    {% set book_list_url =  url('book-list') %}
    {% set book_category_list_url = url('book-category-list') %}
    <ul>
      <li class="navigation__item {% if active_url == book_list_url %} is-active {% endif %}">
        <a class="navigation__link" href="{{ book_list_url }}">Books</a>
      </li>
      <li class="navigation__item {% if active_url == book_category_list_url %} is-active {% endif %}">
        <a class="navigation__link" href="{{ book_category_list_url }}">Categories</a>
      </li>
    </ul>
  </div>
{%- endmacro %}
{% macro SiteHeader(user) -%}
<div class="site-header">
    <a href="/" class="site-header__logo">
        <img class="site-header__logo-img" src="{{ static('base/images/logo.png') }}">
        BookStacks
    </a>
    <div class="site-header__search-form">
        {{ SearchForm() }}
    </div>
    <div class="site-header__accounts d-flex my-auto">
        <button class="btn btn-sm btn-primary mr-3" data-toggle="modal" data-target="#book-upload-form-modal">Upload
        </button>
        {% if user.is_authenticated %}
            <a href="{{ url('logout') }}" class="btn btn-sm btn-outline-secondary mr-2">Logout</a>
        {% endif %}
    </div>
</div>
{%- endmacro %}

Pagination

This component handles pagination based on the page object. Rather than displaying all pages available in the range, it only renders pages with in a certain range from the current page.

{% macro Pagination(page_obj, num_of_visible_pages, base_url="") -%}
{% set num_pages = page_obj.paginator.num_pages %}
{% set current_page_number = page_obj.number %}
{% set visible_range_start = page_obj.number - num_of_visible_pages %}
{% set visible_range_end = num_of_visible_pages + page_obj.number %}
{% set show_last_page = current_page_number < (num_pages - num_of_visible_pages) %}
{# Say num_of_visible_pages = 3 and when the current page(say 5) which is > (3 + 1 = 4), It implies there is atleast #}
{# one page before the visible_range_start ie page 1, page 2 that should be represented with an ellipsis #}
{% set show_left_ellipsis = current_page_number > (num_of_visible_pages + 1) %}
{# Say num_of_visible_pages = 3, the current page is 5 and the total number of pages is 12, #}
{# For all pages after the current page(5) to be shown, the current page number should be with in #}
{# 3 steps to the left from the last page #}
{% set show_right_ellipsis = current_page_number < (num_pages - (num_of_visible_pages + 1)) %}
<div class="d-flex align-items-center justify-content-center">
    <ul class="pagination mt-3">
        {% if page_obj.has_previous() %}
            <li>
                <a class="page-link" href="{{ base_url }}?page={{ page_obj.previous_page_number() }}"> < </a>
            </li>
            {% if page_obj.number > (num_of_visible_pages + 1) %}
                <li class="page-item">
                    <a class="page-link" href="{{ base_url }}?page=1">1</a>
                </li>
            {% endif %}
            {% if show_left_ellipsis %}
                <li class="page-item">
                    <a class="page-link" href="">...</a>
                </li>
            {% endif %}
        {% endif %}
        {% for page_num in page_obj.paginator.page_range %}
            {% if page_num == current_page_number %}
                <li class="page-item active">
                    <a class="page-link" href="{{ base_url }}?page={{ page_num }}">{{ page_num }}</a>
                </li>
            {% elif page_num >= visible_range_start and page_num <= visible_range_end %}
                <li class="page-item">
                    <a class="page-link" href="{{ base_url }}?page={{ page_num }}">{{ page_num }}</a>
                </li>
            {% endif %}
        {% endfor %}

        {% if page_obj.has_next() %}
            {% if show_right_ellipsis %}
                <li class="page-item"><a class="page-link">...</a></li>
            {% endif %}
            {% if show_last_page %}
                <li class="page-item">
                    <a class="page-link" href="{{ base_url }}?page={{ num_pages }}">{{ num_pages }}</a>
                </li>
            {% endif %}
            <li class="page-item">
                <a class="page-link" href="{{ base_url }}?page={{ page_obj.next_page_number() }}"> > </a>
            </li>
        {% endif %}
    </ul>
</div>
{%- endmacro %}

With this, you have all that is necessary to understand how this application works and hopefully extend its functionality.

Areas of improvement

  • Complete unfinished views.
  • Make application responsive.
  • Keep components and their styles in separate files instead of a single file.
  • Reserve state(old submitted values) of book upload form when form is invalid.

HAPPY LEARNING 😃!

PDF Library application in Django [WebDev Project]
Share this