Углубление в Django

Введение

В этом блоке напишем с вами еще один сайт. Есть несколько моментов, которые я не затронул во время написания первых трех сайтов, и для которых мне захотелось выделить отдельный блок. Думаю никто не считает правильным подходом в обучении программированию сразу садиться за большой и сложный проект, начинать нужно с малого, так мы и поступили. Этот блок будет посвящен одному сайту, на примере которого мы окончательно разберемся с типами связей баз данных, с формированием правильных динамических url адресов, с реализацией представлений с помощью классов, с некоторыми сторонними библиотеками, ну и что-то еще вспомнится по мере написания самого проекта.

Не станем выдумывать что-то необычное, напишем сайт-библиотеку, это хороший вариант для работы со связями, у одного автора может быть несколько книг, и у книги может быть несколько авторов.

Типы связей. Подключение PostgeSQL

Создадим проект pylibrary и приложение book и сразу приступим к продумыванию баз данных. Любой проект начинается с этого и лучше отнестись к этому ответственно, поскольку переделывание баз данных уже готовых проектов зачастую может вылиться в неожиданные проблемы и ошибки. Но как обычно, до всех понимание того, что продумывание баз данных это важно придет после того, как они столкнутся с проблема вытекающими из этого на собственных проектах. Давайте также вместо sqlite воспользуемся PostgreSQL.
Тут не будем отвлекаться на установку PostgreSQL и pgAdmin. Сразу перейдем в pgAdmin и создадим новую базу данных.

Назовем базу, как и сам проект - pylibrary. Теперь подключим эту базу к Django. Вот Документация по подсоединению баз данных к вашим проектам. Нашу базу подключим следующим образом.

pylibrary/setting.py
БЫЛО
...
DATABASES = {
    'default': {
        'ENGINE': 'django.db.backends.sqlite3',
        'NAME': BASE_DIR / 'db.sqlite3',
    }
}
СТАЛО
...

DATABASES = {
    'default': {
        'ENGINE': 'django.db.backends.postgresql',
        'NAME': 'pylibrary', (Вставьте название БД)
        'USER': 'ilya', (Вставьте логин от pgAdmin)
        'PASSWORD': 'mypassword', (Вставьте пароль от pgAdmin)
        'HOST': 'localhost',
        'PORT': '5432',
    }
}

Все. База данных добавлена.

Для работы с PostgreSQL в Django нужно установить библиотеку psycopg2. Вот Документация.

После этого воспользуйтесь командой python manage.py migrate и если миграции прошли успешно, то PostgreSQL БД добавлена корректно.

Теперь можно перейти в файл models.py и начать писать БД проекта.

book/models.py
from django.db import models


# Create your models here.
class Category(models.Model):
    name = models.CharField(max_length=150, null=True)

    def __str__(self):
        return self.name


class Book(models.Model):
    title = models.CharField(max_length=150)
    description = models.TextField()
    year = models.PositiveIntegerField()
    category = models.ForeignKey(Category, on_delete=models.SET_NULL, null=True)

    def __str__(self):
        return self.title

Посчитаем, что одна книга может принадлежать только к одной категории, при этом к каждой категории может принадлежать сколько угодно книг. Такая связь называется 'один ко многим' и для этой связи мы используем тип ForeignKey. В данном случае мы написали, что одна книга может выбрать себе любую категорию из таблицы Category при этом, если категория будет удалена, то все книги, к этой категории относящиеся, никуда не денутся. Просто в поле категория для них будет установлено значение NULL, параметром null=True мы как раз допускаем такое поведение.

Перейдя теперь в админ панель мы увидим две наших созданных таблички. Создание категории представлено одним полем, в создании книги появляется поле Category куда подтягиваются значения из таблицы категорий. С этим типом связи мы уже сталкивались, просто закрепили это еще раз. Но существуют еще два типа связи. Первый - 'многие ко многим', второй - 'один к одному' Тип 'один к одному' на самом деле достаточно редок в использовании. Знаете на вопрос 'А когда нужно использовать Метаклассы?' отвечают 'Человек, которому нужно использовать Метаклассы сам прекрасно понимает, когда и почему он должен их использовать'. Со связью 'один к одному' ситуация похожая. Создается эта связь записью models.OneToOneField() первый параметр это модель, с которой мы связываемся, второй on_delete= и доступны прочие необязательные параметры. Связь 'один ко многим' соединяет одну запись первичной модели с одной записью вторичной модели. Хороший пример, на который я натыкался, где можно использовать такую связь, это адрес для ресторанов. Если не ошибаюсь, существует правило, говорящее что, одному адресу может соответствовать только один ресторан. В случае создания такой базы данных мы бы могли воспользоваться этим типом связи.
И последний тип связи это 'многие ко многим'. Используется чаще чем 'один к одному' и реже чем 'один ко многим', но использовать вы его будете все-равно часто. Создается такой связи записью models.ManyToManyField(). В отличии от прошлый типов ManyToManyField не имеет обязательных параметров, кроме названия самой модели, с которой мы связываемся. И называть это поле рекомендуется во множественном числе, что и логично.
В начале мы обусловились, что у книги может быть несколько автором и эти авторы могут написать несколько книг. Воспользуемся ManyToManyField для создания модели авторов.

book/models.py
from django.db import models


# Create your models here.
class Category(models.Model):
    name = models.CharField(max_length=150, null=True)

    def __str__(self):
        return self.name


class Author(models.Model):
    name = models.CharField(max_length=250)
    country = models.CharField(max_length=150, blank=True, null=True)

    def __str__(self):
        return self.name


class Book(models.Model):
    title = models.CharField(max_length=150)
    description = models.TextField()
    year = models.PositiveIntegerField()
    category = models.ForeignKey(Category, on_delete=models.SET_NULL, null=True)
    authors = models.ManyToManyField(Author)

    def __str__(self):
        return self.title

Теперь файл models.py выглядит так. Добавим модель Author, пусть у автора будет имя и страна необязательная к заполнению. После создадим поле authors в модели Book и свяжем ее с моделью Author связью ManyToManyField. Посмотрим, что из этого вышло.

Мы можем добавлять к книге любых авторов из таблицы Authors. Кстати, такой горизонтальный фильтр для перемещения сущностей ManyToMany полей делается следующим образом.

book/admin.py
from django.contrib import admin
from .models import Category, Book, Author


# Register your models here.
class BookAdmin(admin.ModelAdmin):
    filter_horizontal = ('authors',)


admin.site.register(Category)
admin.site.register(Book, BookAdmin)
admin.site.register(Author)

filter_horizontal. Данный параметр отвечает за такое отображение. Есть еще filter_vertical, который делает то же самое, но в вертикальном формате.

Надеюсь теперь мы с вами понимаем устройство баз данных еще лучше.

slug. Динамические url. get_absolute_url()

Повторим, что знали про url ранее и смоделируем ситуацию, где нам нужны динамические url.

На данный момент выглядит стартовая страница следующим образом. Рассмотрим код имеющийся на данный момент. Нового в нем пока мало, разве что способ вывода авторов достаточно неочевиден. Обо всем по порядку.

pylibrary/urls.py
from django.contrib import admin
from django.urls import path, include

urlpatterns = [
    path('admin/', admin.site.urls),
    path('', include('book.urls')),
]
book/urls.py
from django.urls import path
from . import views

urlpatterns = [
    path('', views.home, name='home'),
]

В url ничего нового.

book/views.py
from django.shortcuts import render
from .models import *


# Create your views here.
def home(request):
    category = Category.objects.all()
    book = Book.objects.all()
    authors = Author.objects.all()
    return render(request, 'book/category.html', {'category': category, 'books': book, 'authors': authors})

Во views добавим все наши модели. И пока воспользуемся функциями, классами воспользуемся позже.

book/base.html
{% load static %}

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>{% block title %}{% endblock %}</title>
    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-1BmE4kWBq78iYhFldvKuhfTAU6auU8tT94WrHftjDbrCEXSU1oBoqyl2QvZ6jIW3" crossorigin="anonymous">
    <link rel="stylesheet" href="{% static 'book/style.css' %}">
</head>
<body>
<nav class="navbar navbar-expand-lg navbar-light bg-light">
    <div class="container-fluid">
        <a class="navbar-brand" href="{% url 'home' %}">Библиотека</a>
    </div>
</nav>

<div class="container-fluid">
    <div class="row">
        <div class="col-2">
            {% include 'book/nav.html' %}
        </div>
        <div class="col-10 px-5">
            {% block content %}
            {% endblock %}
        </div>
    </div>
</div>

<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-ka7Sk0Gln4gmtz2MlQnikT1wXgYsOg+OMhuP+IlRH9sENBO0LRn5q+8nbTov4+1p" crossorigin="anonymous"></script>

</body>
</html>
book/category.html
{% extends 'book/base.html' %}

{% block content %}
    {% for book in books %}
        <h2><a href="#" class="link">{{ book.title }}</a></h2>
        {% for author in book.authors.all %}
            <h5>{{ author.name }}</h5>
        {% endfor %}
        <p>Год выпуска - {{ book.year }} год.</p>
    {% endfor %}
{% endblock %}
book/nav.html
<h4>Категории:</h4>
<div class="side_nav">
    <a href="{% url 'home' %}" class="link">Все категории</a><br>
    {% for cat in category %}
        <a href="#" class="link">{{ cat.name }}</a><br>
    {% endfor %}
</div>
book/style.css
.link {
    color: black;
    font-weight: 900;
}

.navbar-brand {
    font-size: 22px;
    font-weight: 900;
}

В шаблонах есть что обсудить.

Тег include. Ранее мы его не использовали, позволяет не унаследоваться от какого-то шаблона, а наоборот поместить один шаблон в другой. Так мы включили шаблон nav.html в base.html. Откуда шаблон nav.html берет информацию о категориях? Шаблон, который мы включаем тегом include имеет доступ ко всей информации, которой владеет 'родительский' шаблон. Таким образом мы теперь будем выводить все имеющиеся в базе категории.

Теперь наверное самое интересное, вывод авторов. Такой подход уместен для любых ManyToMany полей. Файл category.html. В первую очередь пробегаемся по всем книгам и забираем информацию о каждой в переменную book, через которую теперь будем обращаться к индивидуальной информации о каждой книге. И если сейчас обратиться к полю authors через точку мы получим объект None. Для избежания этого мы должны обратиться также к связанной модели в еще одном цикле, обращаемся мы к связанной модели через название ManyToMany поля, authors в нашем случае. Далее с помощью .all заберем все записи и уже их поместим в новую переменную author, через которую обратиться к автору/авторам конкретной книги становится возможным. Надеюсь логика вам ясна.

Теперь что хотелось бы сделать. На данный момент на главную страницу выводятся все книги и находимся мы по дефолту во вкладке 'все категории'. Хотелось бы, чтобы при выборе категории на экране оставались только книги из этой категории. А также, чтобы при клике на название книги мы попадали на ее страницу.

Начнем с личной страницы каждой книги.

book/urls.py
from django.urls import path
from . import views

urlpatterns = [
    path('', views.home, name='home'),
    path('category/<int:book_pk>', views.book_info, name='book_info')
]
book/views.py
def book_info(request, book_pk):
    category = Category.objects.all()
    book = Book.objects.all()
    authors = Author.objects.all()
    return render(request, 'book/book_info.html', {'category': category, 'books': book, 'authors': authors})
book/book_info.html
{% extends 'book/base.html' %}

{% block content %}
    <p>Информация о статье</p>
{% endblock %}
book/category.html
{% extends 'book/base.html' %}

{% block content %}
    {% for book in books %}
        <h2><a href="{% url 'book_info' book.pk %}" class="link">{{ book.title }}</a></h2>
        {% for author in book.authors.all %}
            <h5>{{ author.name }}</h5>
        {% endfor %}    
        <p>Год выпуска - {{ book.year }} год.</p>
    {% endfor %}
{% endblock %}

Добавим динамический url для каждой книги. Напомню как это работает. Внутри знаков больше и меньше создается переменная с именем book_pk, int приравнивает эту переменную к целочисленному типу. Берется эта переменная из функции-представления book_info, мы предали ее вторым параметром. Откуда мы берем это значение? Как вы помните у каждой записи в БД есть уникальный идентификатор, он хранится в поле с именем pk. В шаблоне category.html переменная book хранит информацию о каждом поле каждой книги, в том числе и о pk, соответственно ничего не мешает взять его значение через точку внутри тега url. Первым параметром в этот тег передаем имя динамического пути, а вторым как раз идентификатор, который подставляется после category/. Все личная страница каждой книги сформирована.

Как вы помните 'Грокаем алгоритмы' мы добавили первой, соответственно ей принадлежит идентификатор единица. И при клике на ее название мы как раз переходим на страницу с адресом localhost/category/1.

Но формирование динамического url с помощью тега url это не самый лучший подход, обычно используется функция get_absolute_url().

book/category.html
{% extends 'book/base.html' %}

{% block content %}
    {% for book in books %}
        <h2><a href="{{ book.get_absolute_url }}" class="link">{{ book.title }}</a></h2>
        {% for author in book.authors.all %}
            <h5>{{ author.name }}</h5>
        {% endfor %}
        <p>Год выпуска - {{ book.year }} год.</p>
    {% endfor %}
{% endblock %}
book/models.py
class Book(models.Model):
    title = models.CharField(max_length=150)
    description = models.TextField()
    year = models.PositiveIntegerField()
    category = models.ForeignKey(Category, on_delete=models.SET_NULL, null=True)
    authors = models.ManyToManyField(Author)

    def get_absolute_url(self):
        return reverse('book_info', kwargs={'book_pk': self.pk})

    def __str__(self):
        return self.title

Функция get_absolute_url() пишется в той модели, к записям которой мы хотим формировать динамический url. В нашем случае напишем ее в модели Book. Возвращает она функцию reverse() из модуля django.urls. Функция reverse() формирует url адрес первым параметром она принимает имя нужной функции path(), а вторым то что нужно подставить в переменную этого пути, то есть того, что находится между знаками больше и меньше. В нашем случае переменная имеет имя 'book_pk' и принимать она должна значение поля pk экземпляра модели. Таким образом, если мы заменим значение pk на slug (а скоро мы это сделаем) нам не нужно будет менять в шаблонах ничего, мы по-прежнему будем вызывать этот метод (методы, как вы уже заметили вызываются через точку, что на само деле достаточно очевидно). Нам будет достаточно заменить значение pk внутри самой функции м тип переменной внутри path().

Теперь займемся отображением статей по категориям.

Сделаем пока следующее, пусть при клике на каждую из категорий мы попадем на новую страницу, где будут отображены только книги из этой категории, а при клике на 'Все категории' соответственно будем видеть все книги.

book/urls.py
from django.urls import path
from . import views

urlpatterns = [
    path('', views.home, name='home'),
    path('book/<int:book_pk>', views.book_info, name='book_info'),
    path('category/<int:category_pk>', views.category_info, name='category_info'),
]

Для начала добавим в urls.py новый маршрут по аналогии с маршрутами для книг. book_pk соответственно поменяем на category_pk, отображение реализуем функцией category_info и дадим для пути соответствующее имя. И поменяем префикс для пути до каждой книги на book/, это логичнее.

book/models.py
class Category(models.Model):
    name = models.CharField(max_length=150, null=True)

    def get_absolute_url(self):
        return reverse('category_info', kwargs={'category_pk': self.pk})

    def __str__(self):
        return self.name

В models.py добавим get_absolute_url() для модели Category по аналогии с get_absolute_url() для модели Book.

book/nav.html
<h4>Категории:</h4>
<div class="side_nav">
    <a href="{% url 'home' %}" class="link">Все категории</a><br>
    {% for cat in category %}
        <a href="{{ cat.get_absolute_url }}" class="link">{{ cat.name }}</a><br>
    {% endfor %}
</div>

Для отображения категорий создавать новый шаблон не станем, у нас есть nav.html где мы и выводим все категории. Заглушки у ссылок теперь заменим на .get_absolute_url, а для 'Все категории' так и оставим ссылку на 'home'.

book/views.py
def category_info(request, category_pk):
    category = Category.objects.all()
    book = Book.objects.filter(category_id=category_pk)
    return render(request, 'book/category.html', {'category': category, 'books': book})

И теперь реализуем само отображение. Сделаем это следующим образом, с помощью метода filter() будем отображать только те книги чей category_id совпадает с id выбранной категории. Откуда мы взяли параметр category_id? Давайте перейдем в pgAdmin и взглянем на наши таблицы.

Наши таблицы категорий и книг связаны полем ForeignKey, это поле мы назвали category, в случае ForeignKey суффикс _id добавляется автоматически, потому что связь осуществляется именно по полю id таблицы категорий. Таким образом это поле содержит уникальный идентификатор категории, к которой относится книга. Этот идентификатор мы и видим в адресе каждой категории. Думаю разжевал все максимально подробно.

Теперь у нас есть личная страница каждой книги и отображение книг по категориям.

И осталось заняться отображением slug'а вместо id.

Что вообще за slug. slug это можно сказать обычное текстовое поле, из которого убраны недоступные для поисковых строк символы. Вместо id нам бы хотелось видеть slug, который отражает суть страницы, на которой мы находимся. slug лучше ранжируется поисковыми системами и более нагляден для пользователя.

Теперь наши адреса отображаются следующим образом. Давайте разберемся как это сделать.

book/models.py
class Category(models.Model):
    name = models.CharField(max_length=150, null=True)
    slug = models.SlugField(max_length=150, unique=True, db_index=True)

 

class Book(models.Model):
    title = models.CharField(max_length=150)
    slug = models.SlugField(max_length=150, unique=True, db_index=True)
    description = models.TextField()
    year = models.PositiveIntegerField()
    category = models.ForeignKey(Category, on_delete=models.SET_NULL, null=True)
    authors = models.ManyToManyField(Author)

Для начала перейдем в models.py и добавим поле slug для моделей категорий и книг. Поле slug создается с помощью .SlugField. Поскольку это текстовое поле наследуемое от CharField параметр max_length является обязательным, зададим длину как у поля названия, параметр unique=True не позволит сохранить значение этого поля, если оно не уникально для данной таблицы, мы хотим иметь уникальный адрес для каждой записи. Параметр db_index=True делает поле индексируемым, то есть поиск записей из БД будет происходить в приоритете по этому полю. Наличие этого параметра именно у поля slug ускоряет отображение записей из БД на сайте, поскольку именно по slug мы будем обращаться к записям.
Правда если сейчас попробовать создать новые миграции мы увидим следующее предупреждение.

Это предупреждение говорит о том, что мы хотим поместить новое поле в таблицы, которые уже содержат записи. Соответственно раз к этим записям добавляется новое поле, то оно должно быть заполнено. Для того чтобы обойти это предупреждение, добавим к полю slug еще один параметр.

book/models.py
class Category(models.Model):
    name = models.CharField(max_length=150, null=True)
    slug = models.SlugField(max_length=150, unique=True, db_index=True, null=True)

 

class Book(models.Model):
    title = models.CharField(max_length=150)
    slug = models.SlugField(max_length=150, unique=True, db_index=True, null=True)
    description = models.TextField()
    year = models.PositiveIntegerField()
    category = models.ForeignKey(Category, on_delete=models.SET_NULL, null=True)
    authors = models.ManyToManyField(Author)

Параметр null в значении True разрешит полю быть незаполненным. И теперь мы можем провести миграции и зайти в панель администратора для просмотра изменений.

Посмотрим на примере категории, появилось новое текстовое поле, которые мы можем заполнить вручную. Но обычно принято делать поле slug автозаполняемым и происходит обычно это автозаполнение по подобию поля с названием. Давайте тоже сделаем это поле таким.

book/admin.py
class BookAdmin(admin.ModelAdmin):
    filter_horizontal = ('authors',)
    prepopulated_fields = {'slug': ('title',)}


class CategoryAdmin(admin.ModelAdmin):
    prepopulated_fields = {'slug': ('name',)}


admin.site.register(Category, CategoryAdmin)
admin.site.register(Book, BookAdmin)

....

Для этого перейдем в admin.py приложения book и добавим в администраторские классы модели параметр prepopulated_fields. В нем мы говорим какое поле должно заполняться автоматически и на примере какого поля это должно происходить.

Поле slug повторяет за нами символы, которые мы пишем в поле с названием. Теперь можно пройтись по всем записям и просто кликнуть на поле с названием, slug для этой записи после этого заполнится автоматически.

Осталось только вывести эти значения вместо цифр.

book/urls.py
...
urlpatterns = [
    path('', views.home, name='home'),
    path('book/<slug:book_slug>', views.book_info, name='book_info'),
    path('category/<slug:category_slug>', views.category_info, name='category_info'),
]

Для url переменных существует отдельный тип данных - slug, прировняем к нему передаваемые переменные и заменим их названия на более логичные.

book/views.py
def book_info(request, book_slug):
    category = Category.objects.all()
    book = Book.objects.all()
    authors = Author.objects.all()
    return render(request, 'book/book_info.html', {'category': category, 'books': book, 'authors': authors})


def category_info(request, category_slug):
    category = Category.objects.all()
    book = Book.objects.filter(category__slug=category_slug)
    return render(request, 'book/category.html', {'category': category, 'books': book})

Во views.py также заменим названия этих переменных. И поскольку мы теперь не можем отфильтровать записи по category_id мы обратимся к полю slug таблицы category. Для обращения через фильтр к записям одной таблицы через другую нужно написать 'название таблицы' 'два нижних подчеркивания' 'название поля'. Таким образом мы выбираем только те книги, которые связаны с категорией, slug которой передан в адресную строку.
(Дублирование кода во views.py наверное мозолит вам глаза, уберем его немного позже.)

book/models.py
class Category(models.Model):
    name = models.CharField(max_length=150, null=True)
    slug = models.SlugField(max_length=150, unique=True, db_index=True, null=True)

    def get_absolute_url(self):
        return reverse('category_info', kwargs={'category_slug': self.slug})

 

class Book(models.Model):
    title = models.CharField(max_length=150)
    slug = models.SlugField(max_length=150, unique=True, db_index=True, null=True)
    description = models.TextField()
    year = models.PositiveIntegerField()
    category = models.ForeignKey(Category, on_delete=models.SET_NULL, null=True)
    authors = models.ManyToManyField(Author)

    def get_absolute_url(self):
        return reverse('book_info', kwargs={'book_slug': self.slug})

И последний штрих. Заменим self.id в get_absolute_url() на self.slug и имена переменных соответственно тоже заменим на новые.
А в шаблоны лезть нам вообще не нужно, поскольку мы пользуемся get_absolute_url(). То о чем я говорил ранее, достаточно пары изменений в одной функции и все пути теперь отображаются по slug, что куда понятнее как для пользователей, так и для поисковых систем.

Теперь добавим вывод информации о книге.

book/views.py
def book_info(request, book_slug):
    book = get_object_or_404(Book, slug=book_slug)
    category = Category.objects.all()
    return render(request, 'book/book_info.html', {'category': category, 'books': book})

Для вывода статей воспользуемся функцией get_object_or_404(), чтобы в случае введения пользователем несуществующего адреса в адресную строку получал 404 ошибку. А отсортируем книги по slug.

book/book_info.html
{% extends 'book/base.html' %}

{% block content %}
    <h2 class="text-center">{{ books.title }}</h2>
    <p class="text-center">{{ books.category }}</p>
    {% for author in books.authors.all %}
        <h5 class="text-end">{{ author.name }}</h5>
    {% endfor %}
    <p class="px-3 mt-5">{{ books.description }}</p>
    <p class="text-end fw-bold">{{ books.year }}</p>
{% endblock %}

И добавим простенькую структуру для информации о книге.

В итоге у каждой книги теперь есть личная страница со всей информацией, в том числе категорией и авторами.

Пользовательские теги. simple_tag

Мы в каждом view выбираем все записи из таблицы Category, давайте уберем это повторение кода с помощью пользовательского тега.

pylibrary/book/templatetags/main_tags.py
from django import template
from book.models import *

register = template.Library()


@register.simple_tag()
def get_category():
    return Category.objects.all()

Для создания пользовательских тегов создадим директорию templatetags в папке приложения book, в этой директории создадим пустой файл __init__.py для того, чтобы папка воспринималась как пакет. И также создадим там файл main_tags.py, где будем писать наши теги. Для начала импортируем template из django и модели нашего приложения. Строкой register = template.Library() мы создаем переменную register равную экземпляру django.template.Library, теперь этой переменной мы можем регистрировать пользовательские теги. Существует два типа тегов простые и включающие, сейчас коснемся только простого. Создадим функцию, которая будет возвращать все записи таблицы Category и обернем эту функцию в декоратор @register.simple_tag(), где register наша переменная, а simple_tag тип тега. Теперь мы можем использовать этот тег внутри шаблонов.

book/nav.html
{% load main_tags %}
<h4>Категории:</h4>
<div class="side_nav">
    <a href="{% url 'home' %}" class="link">Все категории</a><br>
    {% get_category as category %}
        {% for cat in category %}
            <a href="{{ cat.get_absolute_url }}" class="link">{{ cat.name }}</a><br>
        {% endfor %}
</div>

Для того чтобы пользоваться пользовательскими тегами, нужно загрузить файл, в котором они находятся в шаблон, в котором мы хотим их использовать. Делается это тегом с ключевым словом load, после которого идет название файла с тегами. Далее мы можем загрузить тег в шаблон, и тогда мы увидим все его содержимое. А можем загрузить его как на примере с помощью ключевого слова as, тогда мы сможем обращаться к его содержимому в теге for.
Теперь из всех view мы можем удалить строку category = Category.objects.all() и в работе нашего сайта ничего не измениться.

Давайте напишем еще один простой тег для промежуточного закрепления.

pylibrary/book/templatetags/main_tags.py
@register.simple_tag(name='main_page_info')
def main_info():
    return Book.objects.all()

Добавим тег для вывода всех книг. В нем нет ничего нового, кроме параметра name=, если мы задаем этот параметр, то в шаблоне к этому тегу мы теперь будем обращаться по этому имени.

book/main.html
{% extends 'book/base.html' %}
{% load main_tags %}

{% block content %}
    {% main_page_info as books %}
        {% for book in books %}
        <h2><a href="{{ book.get_absolute_url }}" class="link">{{ book.title }}</a></h2>
            {% for author in book.authors.all %}
                <h5>{{ author.name }}</h5>
            {% endfor %}
        <p>Год выпуска - {{ book.year }} год.</p>
        {% endfor %}
{% endblock %}
book/books_for_category.html
{% extends 'book/base.html' %}

{% block content %}
    {% for book in books %}
    <h2><a href="{{ book.get_absolute_url }}" class="link">{{ book.title }}</a></h2>
        {% for author in book.authors.all %}
            <h5>{{ author.name }}</h5>
        {% endfor %}
    <p>Год выпуска - {{ book.year }} год.</p>
    {% endfor %}
{% endblock %}

Таким образом переберем все книги в шаблоне main.html уже через этот тег. Также разделим вывод по категориям и вывод всех книг на два разных шаблона, ранее мы ссылались на один шаблон и полную выборку из модели Book и выборку из этой же модели по определенному условию мы передавали в один шаблон под одним именем, это не очень правильный подход, он легко запутает не только того кто читает код, но и того кто этот код писал.

book/views.py
def home(request):
    return render(request, 'book/main.html')


def book_info(request, book_slug):
    book = get_object_or_404(Book, slug=book_slug)
    return render(request, 'book/book_info.html', {'books': book})


def category_info(request, category_slug):
    book = Book.objects.filter(category__slug=category_slug)
    return render(request, 'book/books_for_category.html', {'books': book})

К пользовательским тегам мы вернемся далее, этот маленький блок небольшое вступление, для простого понимания, что такой функционал есть в django. И как он может помочь справиться с дублированием вашего кода.

Классы представлений. ListView. DetailView

Ранее мы с вами для представлений использовали только функции, но на практике использование функций вы встретите достаточно редко. Использование классов по итогу делает код представлений куда понятнее. Давайте заменим все функции представления на классы.

book/views.py
from django.views.generic import ListView
from .models import *


class HomeView(ListView):
    model = Book
    template_name = 'book/main.html'
    context_object_name = 'books'
    extra_context = {'title': 'Библиотека'}


# def home(request):
#     return render(request, 'book/main.html')

Функции render() и get_object_or_404() нам более не нужны, вместо этого импортируем первый тип классов представлений - ListView. На самом деле разнообразность классов представлений достаточно высокая, но коснемся мы пока только двух, которые чаще всего используются на практике.
ListView отвечает за отображение записей списком, например, вывод категорий, вывод списка записей и подобные отображения. На главной странице мы выводим список книг, поэтому ListView нам подойдет. Для создания класса представлений дадим ему название отражающее суть представления и унаследуемся от ListView.
Теперь мы можем описывать наш класс при помощи параметров. Параметр 'model' содержит название модели. Параметр 'template_name' содержит название шаблона, по умолчанию, если этот параметр не определен, будет искаться шаблон вида 'имя модели_view', в нашем случае, если бы этот параметр не был определен, искался бы шаблон book_view. Параметр 'context_object_name' содержит название переменной, по которому мы будем обращаться к записям выбранным в этом классе, по умолчанию эта переменная имеет название object_list. И еще определим в этом классе словарь extra_context, ключом будет имя, по которому внутри шаблона мы сможем обращаться к значению этого ключа. В базовом шаблоне у нас определен block title, но ранее мы его не заполняли, теперь давайте это исправим, и для главной страницы передадим название 'Библиотека'. Все, представление для главной страницы переопределено в виде класса. И в использовании пользовательского тега main_page_info мы более не нуждаемся.

book/views.py
class CategoryInfoView(ListView):
    model = Book
    template_name = 'book/books_for_category.html'
    context_object_name = 'books'

    def get_context_data(self, *, object_list=None, **kwargs):
        context = super().get_context_data(**kwargs)
        context['title'] = str(context['books'][0].category)
        return context

    def get_queryset(self):
        return Book.objects.filter(category__slug=self.kwargs['category_slug'])


# def category_info(request, category_slug):
#     book = Book.objects.filter(category__slug=category_slug)
#     return render(request, 'book/books_for_category.html', {'books': book})

Далее напишем класс для отображения книг по категориям. Из нового здесь методы. Метод get_context_data() переопределяет возвращаемый context. context эта та переменная, которая содержит словарь ключей, по которым мы обращаемся внутри шаблона. Для начала функцией super() мы должны переопределить context с учетом ранее переданных в эту переменную данных. В данном случае этой строкой мы добавили в переменную 'context' ключ 'books'. И далее в теле функции get_context_data() мы можем добавлять в context новые ключи и значения. Добавим с вами ключ 'title', который будет содержать название категории, на странице которой мы находимся. Мы обращаемся к ключу 'books' и выбираем из него первую запись, поскольку мы предполагаем, что каждая запись имеет хотя бы одну запись и берем у этой записи значение поля category. Поскольку переходя в каждую их категорий мы будем видеть только записи этой категории, обращение к полю category любой записи этой страницы всегда будет возвращать верное имя категории.
И после определим метод get_queryset(). В этом методе мы записываем необходимые условия вывода записей переданной модели. Только обращаться к полю 'category_slug' мы будем теперь через экземпляр класса, и поскольку переменная kwargs хранит всю информацию об экземпляре, помимо прочего, в ней хранится и переменная 'category_slug', содержащая slug конкретной записи.

book/views.py
from django.views.generic import ListView, DetailView
from .models import *


class BookDetailView(DetailView):
    model = Book
    template_name = 'book/book_info.html'
    context_object_name = 'books'
    slug_url_kwarg = 'book_slug'
    allow_empty = False

    def get_context_data(self, **kwargs):
        context = super().get_context_data(**kwargs)
        context['title'] = str(context['books'].title)
        return context

    def get_queryset(self):
        return Book.objects.filter(slug=self.kwargs['book_slug'])


# def book_info(request, book_slug):
#     book = get_object_or_404(Book, slug=book_slug)
#     return render(request, 'book/book_info.html', {'books': book})

И осталось описать класс для вывода информации о конкретной книге, для вывода детальной информации о записи используется класс DetailView. Из новых параметров класс DetailView содержит slug_url_kwarg, где мы определяем имя переменной из urls.py, которая содержит slug для формирования пути этой записи, либо используется переменная pk_url_kwarg в случае когда отображение происходит по внешнему ключу, по умолчанию эти переменные содержат значения 'slug' и 'pk' соответственно. Параметр allow_empty в значении False заменяет get_object_or_404(), в случае передачи несуществующего slug'а будет возвращаться 404. С get_context_data() и get_queryset() ситуация такая же как и в прошлом классе, ничего нового.

book/urls.py
from django.urls import path
from . import views

urlpatterns = [
    path('', views.HomeView.as_view(), name='home'),
    path('book/<slug:book_slug>', views.BookDetailView.as_view(), name='book_info'),
    path('category/<slug:category_slug>', views.CategoryInfoView.as_view(), name='category_info'),
]

Осталось заменить названия функций в файле urls.py на названия соответствующих классов использую при этом метод as_view().

book/main.html
{% extends 'book/base.html' %}

{% block title %}{{ title }}{% endblock %}

{% block content %}
        {% for book in books %}
        <h2><a href="{{ book.get_absolute_url }}" class="link">{{ book.title }}</a></h2>
            {% for author in book.authors.all %}
                <h5>{{ author.name }}</h5>
            {% endfor %}
        <p>Год выпуска - {{ book.year }} год.</p>
        {% endfor %}
{% endblock %}
book/books_for_category.html
{% extends 'book/base.html' %}

{% block title %}{{ title }}{% endblock %}

{% block content %}
    {% for book in books %}
    <h2><a href="{{ book.get_absolute_url }}" class="link">{{ book.title }}</a></h2>
        {% for author in book.authors.all %}
            <h5>{{ author.name }}</h5>
        {% endfor %}
    <p>Год выпуска - {{ book.year }} год.</p>
    {% endfor %}
{% endblock %}
book/book_info.html
{% extends 'book/base.html' %}

{% block title %}{{ title }}{% endblock %}

{% block content %}
    <h2 class="text-center">{{ books.title }}</h2>
    <p class="text-center">{{ books.category }}</p>
    {% for author in books.authors.all %}
        <h5 class="text-end">{{ author.name }}</h5>
    {% endfor %}
    <p class="px-5 mt-5">{{ books.description }}</p>
    <p class="text-end fw-bold">{{ books.year }}</p>
{% endblock %}
book/nav.html
{% load main_tags %}
<h4>Категории:</h4>
<div class="side_nav">
    <a href="{% url 'home' %}" class="link">Все категории</a><br>
    {% get_category as category %}
        {% for cat in category %}
            <a href="{{ cat.get_absolute_url }}" class="link">{{ cat.name }}</a><br>
        {% endfor %}
</div>

Во всех шаблонах, кроме nav.html, выведем переменную title в соответствующем блоке и удалим тег main_page_info из main.html, тег останется у нас только один, предназначенный для отображения категорий.

Сайт с наружи по прежнему выглядит раньше, за исключением отображения заголовков страницы, но за то внутри он теперь выглядит куда приятнее.

Пагинация

На данный момент мы добавили на сайт только шесть записей, но предполагается что их будет больше и размещение всех записей на одной странице не очень удачная практика. Для разделения записей на страницы воспользуемся пагинацией. В django она реализована невероятно удобно. Для начала рекомендую ознакомиться с Документацией, в первом примере очень понятным образом представлены основные возможности объекта Paginator, а далее показывается как можно им пользоваться в представлениях унаследованных от ListView и предоставлениях реализованных в виде обычных функций. Мы к этому моменту уже пользуемся классами, поэтому рассматривать реализацию для функций мы не станем, самостоятельное ознакомление с ней не должно вызвать никаких трудностей.
Также есть API Reference, где более подробно можно почитать о методах пагинатора.

book/views.py
class HomeView(ListView):
    paginate_by = 2
    model = Book
    template_name = 'book/main.html'
    context_object_name = 'books'
    extra_context = {'title': 'Библиотека'}

Добавим в начало класса домашней страницы строку paginate_by = 2. Двойка означает, что мы будем выводить на одну страницу только две записи. Все, вот такой одной строкой мы добавили пагинацию. Теперь context, возвращаемый нашим классом, содержит еще две переменные: paginator и page_obj. Соответственно теперь мы можем обращаться к этим переменным внутри шаблона.

Теперь на главной страницы мы видим только две записи вместо шести. Возникает вопрос, а как попасть ну другие страницы?

Для перемещения по страницам передадим в запрос строку /?page=(номер страницы), мы можем воспользоваться этим запросом внутри шаблона.

book/base.html
% block content %}
{% endblock %}
<nav aria-label="Page navigation example">
    <ul class="pagination">
        <li class="page-item">
            <a class="page-link" href="?page=#" aria-label="Previous">
                <span aria-hidden="true">&laquo;</span>
            </a>
        </li>
        {% for page in paginator.page_range %}
        <li class="page-item">
            <a class="page-link" href="?page={{ page }}">{{ page }}</a>
        </li>
        {% endfor %}
        <li class="page-item">
            <a class="page-link" href="#" aria-label="Next">
                <span aria-hidden="true">&raquo;</span>
            </a>
        </li>
    </ul>
</nav>

Разместим разметку пагинации именно в базовом шаблоне, поскольку paginate_by мы можем захотеть использовать в нескольких представлениях, а раз context представлений всегда содержит одноименные переменные пагинатора мы можем описать поведение пагинации каждого представления в одном месте. Шаблон пагинации возьмем из bootstrap. Добавим его сразу после блока с контентом. Теперь мы можем обратиться к paginator и воспользоваться методом .page_rang. Этот метод выводит уже сгруппированные по страницам записи, в нашем случае мы разбили шесть наших записей на три страницы по две записи и .page_range возвращает нам эти три страницы, то есть буквально цифры 1, 2, 3. И поместить каждую страницу мы можем в переменную, page в нашем случае, и обращаться к ней в цикле. Будем подставлять нужную страницу в ссылку запроса и в саму кнопку навигации.

Теперь под записями появилась работающая панель постраничной навигации. Правда работаю сейчас только кнопки с цифрами, стрелки влево и вправо у нас пока не работают. Еще раз повторюсь, количество кнопок в нашем случае формируется методом .page_range.

Также давайте с помощью класса disabled сделаем отображение той страницы, на которой мы находимся.

book/base.html
<nav aria-label="Page navigation example">
    <ul class="pagination">
        <li class="page-item">
            <a class="page-link" href="?page=#" aria-label="Previous">
                <span aria-hidden="true">&laquo;</span>
            </a>
        </li>
        {% for page in paginator.page_range %}
        {% if page_obj.number == page %}
        <li class="page-item disabled">
            <a class="page-link" href="#">{{ page }}</a>
        </li>
        {% else %}
        <li class="page-item">
            <a class="page-link" href="?page={{ page }}">{{ page }}</a>
        </li>
        {% endif %}
        {% endfor %}
        <li class="page-item">
            <a class="page-link" href="#" aria-label="Next">
                <span aria-hidden="true">&raquo;</span>
            </a>
        </li>
    </ul>
</nav>

Для этого воспользуемся методом .number объекта page_obj, который возвращает номер текущей страницы и сравниваем это значение с переменной page, которая также хранит номер текущей страницы и в случае совпадения будем применять класс disabled.

И осталось учесть еще пару моментов.

book/base.html
{% if page_obj.has_other_pages %}
<nav aria-label="Page navigation example">
    <ul class="pagination">
        {% if page_obj.has_previous %}
        <li class="page-item">
            <a class="page-link" href="?page={{ page_obj.previous_page_number }}" aria-label="Previous">
                <span aria-hidden="true">&laquo;</span>
            </a>
        </li>
        {% endif %}
        {% for page in paginator.page_range %}
        {% if page_obj.number == page %}
        <li class="page-item disabled">
            <a class="page-link" href="#">{{ page }}</a>
        </li>
        {% else %}
        <li class="page-item">
            <a class="page-link" href="?page={{ page }}">{{ page }}</a>
        </li>
        {% endif %}
        {% endfor %}
        {% if page_obj.has_next %}
        <li class="page-item">
            <a class="page-link" href="?page={{ page_obj.next_page_number }}" aria-label="Next">
                <span aria-hidden="true">&raquo;</span>
            </a>
        </li>
        {% endif %}
    </ul>
</nav>
{% endif %}

Первым делом давайте запрограммируем стрелки вправо и влево, для этого воспользуемся методами .has_previous и .has_next объекта page_obj, эти методы возвращают True, если предыдущий и следующий соответственно объекты имеются и False, если нет. И методами .previous_page_number и .next_page_number того же объекта page_obj будем переходить к предыдущей и следующей странице от текущей. И также добавим отображение меню пагинации только на тех страницах, где количество записей превышает число переданное в paginate_by. Для этого всю панель пагинации обернем в условие проверки метода .has_other_pages, который возвращает True, если количество записей возможно разбить по страницам.

book/views.py
class HomeView(ListView):
    paginate_by = 2
...

class CategoryInfoView(ListView):
    paginate_by = 2
...

И не забудьте paginate_by добавить также в CategoryInfoView представление.

Правда теперь при переходе по страницам мы будем видеть в консоли следующее предупреждение.

[23/Apr/2022 08:40:13] "GET /book/dzhanki HTTP/1.1" 200 2742
/home/tsarkoilya/kavo/KAVO/venv/lib/python3.8/site-packages/django/views/generic/list.py:91: UnorderedObjectListWarning: Pagination may yield inconsistent results with an unordered object_list:
<class'book.models.Book'> QuerySet.

Возникает оно по той причине, что для пагинатора очень важен факт сортировки записей. Для сортировки добавим в модели метакласс, где и пропишем сортировку.

book/models.py
class Category(models.Model):
...
    class Meta:
        ordering = ['id']

 

class Book(models.Model):
...
    class Meta:
        ordering = ['id']

Метакласс обычно записывается в конце модели и может содержать разные полезные параметры, например, verbose_name изменить название модели внутри admin панели, а verbose_name_plural изменить название множественного числа, по умолчанию на конец добавляется 's'. Мы же воспользуемся параметром ordering, который отвечает за сортировку. В этот параметр передается название поля, по которому мы хотим отсортировать записи. Можно передать одно поле, можно несколько, в случае нескольких полей сортировка идет по порядку, то есть сначала отсортируются все записи по первому полю, затем по следующему и так далее, и также можно поставить знак 'минус' перед названием поля для обратной сортировки. В нашем случае будем сортировать записи по названию в алфавитном порядке. Теперь, если заглянуть в консоль данного предупреждения мы более не увидим.

Mixin

Думаю вы уже обратили внимание, что в каждой модели у нас наблюдается дублирование кода, мы везде пишем название одной и той же модели, у нас одинаковый параметр context_object_name и также в двух моделях повторяется строка paginate_by. Все это можно вынести во вспомогательный класс Mixin. Для миксинов обычно создается отдельный файл внутри нужного приложения с названием utils.py, создадим такой же файл и поместим в него следующий код.

book/utils.py
from .models import *


class DataMixin:
    model = Book
    paginate_by = 2
    context_object_name = 'books'

В класс DataMixin мы просто вынесли повторяющиеся строки.

book/views.py
from django.views.generic import ListView, DetailView
from .models import *
from .utils import DataMixin


# Create your views here.

class HomeView(DataMixin, ListView):
    template_name = 'book/main.html'
    extra_context = {'title': 'Библиотека'}


class BookDetailView(DataMixin, DetailView):
    template_name = 'book/book_info.html'
    slug_url_kwarg = 'book_slug'
    allow_empty = False

    def get_context_data(self, **kwargs):
        context = super().get_context_data(**kwargs)
        context['title'] = str(context['books'].title)
        return context

    def get_queryset(self):
        return Book.objects.filter(slug=self.kwargs['book_slug'])


class CategoryInfoView(DataMixin, ListView):
    template_name = 'book/books_for_category.html'

    def get_context_data(self, *, object_list=None, **kwargs):
        context = super().get_context_data(**kwargs)
        context['title'] = str(context['books'][0].category)
        return context

    def get_queryset(self):
        return Book.objects.filter(category__slug=self.kwargs['category_slug'])

И теперь наши классы стали немного компактнее, благодаря множественному наследованию унаследуемся сначала от DataMixin, а потом от View классов. Из материала по ООП мы помним, что при множественном наследовании порядок играет значение, если у нас имеются одинаковые атрибуты в классах, от которых мы унаследовались, то будут выбраны атрибуты именно первого переданного класса.

Вспомогательные библиотеки на примере CKEditor

Django очень популярный фреймворк и разумеется, для django существует больше количество вспомогательных библиотек, в которых уже реализован какой-то полезный функционал и зная эти библиотеки вам зачастую не придется изобретать велосипед. Наиболее полное собрание библиотек, насколько известно мне, собраны на данном ресурсе, библиотеки тут разбиты по категориям, имеют краткое описание и ссылку на github, очень советую не поленится и хотя бы разок ознакомится с этим списком, не сомневаюсь вы подчеркнете для себя интересные решения для ваших будущих проектов. Мы же обратимся к категории WYSIWYG Editors и воспользуемся первой библиотекой от туда. django-ckeditor. Удобно, что мы сразу попадаем на github с подробной инструкцией по использованию. Давайте повторим эти шаги и добавим Django CKEditor в свой проект.

Первым делом, конечно, мы должны установить эту библиотеку. Для этого воспользуемся командой:
pip install django-ckeditor
После добавим 'ckeditor' в список установленных приложений в файле settings.py
И сразу же добавим еще одно приложение 'ckeditor_uploader', оно понадобится нам для работы с загружаемыми в проект файлами.
Для работы с загружаемыми файлами нам нужно написать путь для этих файлов, прописывается он в переменной CKEDITOR_UPLOAD_PATH файла settings.py, первым параметром автоматически подставляется MEDIA_ROOT, а после создается папка, название которой записано в CKEDITOR_UPLOAD_PATH.
Еще нам будет необходимо добавить в наш головной urls.py строку
path('ckeditor/', include('ckeditor_uploader.urls')),.

book/urls.py
...
urlpatterns = [
    path('admin/', admin.site.urls),
    path('', include('book.urls')),
    path('ckeditor/', include('ckeditor_uploader.urls')),
]
if settings.DEBUG:
    urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
book/settings.py
...
import os
...
INSTALLED_APPS = [
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',

    # UTILS
    'ckeditor',
    'ckeditor_uploader',

    # MY_APPS
    'book',
]
...
STATIC_URL = 'static/'
STATIC_DIR = os.path.join(BASE_DIR, 'static')
STATICFILES_DIRS = [STATIC_DIR]
# STATIC_ROOT = os.path.join(BASE_DIR, 'static')
MEDIA_URL = '/media/'
MEDIA_ROOT = os.path.join(BASE_DIR, 'media')

CKEDITOR_UPLOAD_PATH = "uploads/"
...

Вот так выглядят преднастройки для использования ckeditor, осталось внести изменения в models.py и admin.py. Про настройку для статических файлов тоже не забывайте.

book/models.py
from django.db import models
from django.urls import reverse
from ckeditor.fields import RichTextField


class Book(models.Model):
    ...
    description = RichTextField()
    ...
book/admin.py
from django.contrib import admin
from django import forms
from .models import Category, Book, Author
from ckeditor_uploader.widgets import CKEditorUploadingWidget


# Register your models here.
class BookAdminForm(forms.ModelForm):
    description = forms.CharField(widget=CKEditorUploadingWidget())

    class Meta:
        model = Book
        fields = '__all__'


class BookAdmin(admin.ModelAdmin):
    form = BookAdminForm
    filter_horizontal = ('authors',)
    prepopulated_fields = {'slug': ('title',)}

...
admin.site.register(Book, BookAdmin)
admin.site.register(Author)

В models.py заменим models.TextField() у поля description модели Book, на RichTextField(), предварительно импортировав этот тип из ckeditor.fields.

В admin.py создадим форму для модели Book по инструкции от авторов ckeditor и не забудем добавить форму в BookAdmin.

Теперь перейдя в admin панель мы увидим новое поле description с возможностью гибкого редактирования текста, добавления изображений и это еще не все.
ckeditor может иметь очень гибкую настройку в самом конце инструкции есть пример настроек, давайте скопируем их и добавим в файл settings.py.

book/settings.py
CKEDITOR_CONFIGS = {
    'default': {
        'skin': 'moono',
        # 'skin': 'office2013',
        'toolbar_Basic': [
            ['Source', '-', 'Bold', 'Italic']
        ],
        'toolbar_YourCustomToolbarConfig': [
            {'name': 'document', 'items': ['Source', '-', 'Save', 'NewPage', 'Preview', 'Print', '-', 'Templates']},
            {'name': 'clipboard', 'items': ['Cut', 'Copy', 'Paste', 'PasteText', 'PasteFromWord', '-', 'Undo', 'Redo']},
            {'name': 'editing', 'items': ['Find', 'Replace', '-', 'SelectAll']},
            {'name': 'forms',
             'items': ['Form', 'Checkbox', 'Radio', 'TextField', 'Textarea', 'Select', 'Button', 'ImageButton',
                       'HiddenField']},
            '/',
            {'name': 'basicstyles',
             'items': ['Bold', 'Italic', 'Underline', 'Strike', 'Subscript', 'Superscript', '-', 'RemoveFormat']},
            {'name': 'paragraph',
             'items': ['NumberedList', 'BulletedList', '-', 'Outdent', 'Indent', '-', 'Blockquote', 'CreateDiv', '-',
                       'JustifyLeft', 'JustifyCenter', 'JustifyRight', 'JustifyBlock', '-', 'BidiLtr', 'BidiRtl',
                       'Language']},
            {'name': 'links', 'items': ['Link', 'Unlink', 'Anchor']},
            {'name': 'insert',
             'items': ['Image', 'Flash', 'Table', 'HorizontalRule', 'Smiley', 'SpecialChar', 'PageBreak', 'Iframe']},
            '/',
            {'name': 'styles', 'items': ['Styles', 'Format', 'Font', 'FontSize']},
            {'name': 'colors', 'items': ['TextColor', 'BGColor']},
            {'name': 'tools', 'items': ['Maximize', 'ShowBlocks']},
            {'name': 'about', 'items': ['About']},
            '/',  # put this to force next toolbar on new line
            {'name': 'yourcustomtools', 'items': [
                # put the name of your editor.ui.addButton here
                'Preview',
                'Maximize',

            ]},
        ],
        'toolbar': 'YourCustomToolbarConfig',  # put selected toolbar config here
        # 'toolbarGroups': [{ 'name': 'document', 'groups': [ 'mode', 'document', 'doctools' ] }],
        # 'height': 291,
        # 'width': '100%',
        # 'filebrowserWindowHeight': 725,
        # 'filebrowserWindowWidth': 940,
        # 'toolbarCanCollapse': True,
        # 'mathJaxLib': '//cdn.mathjax.org/mathjax/2.2-latest/MathJax.js?config=TeX-AMS_HTML',
        'tabSpaces': 4,
        'extraPlugins': ','.join([
            'uploadimage',  # the upload image feature
            # your extra plugins here
            'div',
            'autolink',
            'autoembed',
            'embedsemantic',
            'autogrow',
            # 'devtools',
            'widget',
            'lineutils',
            'clipboard',
            'dialog',
            'dialogui',
            'elementspath'
        ]),
    }
}

Говорю я об этих настройках, которые поместим в самый конец файла settings.py, добавим их вообще без изменений.

Теперь поле description имеет более богатый набор инструментов.

Согласитесь работать с текстом записей на сайте с подобным инструментом куда удобнее. Давайте добавим немного описания для какой-нибудь книги.

Например, возьмем картинку и добавим описание, при добавлении изображения мы модем сразу прописать какой класс стиля будет использоваться, задать размеры при необходимости, добавить отступы и прочие удобные настройки, рекомендую со всем ознакомиться самостоятельно. Теперь посмотрим, что из этого вышло.

Как видно теги так и остались в виде тегов, давайте это поправим. Структура корявенькая, но это потому что я просто скопировал описание с ozone, конечно, над html структурой то же нужно чуть-чуть поработать, но мы сейчас этим заниматься не станем.

book/book_info.html
{% extends 'book/base.html' %}

{% block title %}{{ title }}{% endblock %}

{% block content %}
    <h2 class="text-center">{{ books.title }}</h2>
    <p class="text-center">{{ books.category }}</p>
    {% for author in books.authors.all %}
        <h5 class="text-end">{{ author.name }}</h5>
    {% endfor %}
    <p class="px-5 mt-5">{{ books.description|safe }}</p>
    <p class="text-end fw-bold">{{ books.year }}</p>
{% endblock %}

Для воспроизведения тегов внутри шаблона для этого поля нужно использовать специальный тег, тег |safe. Добавим его для поля description в файле book_info.html.

Теперь при перезагрузке страницы мы увидим, что теги прочитались и мы видим содержимое в таком виде как и задумывалось.

Так на примере ckeditor мы рассмотрели принцип работы со сторонними инструментами для django, еще раз повторюсь, не поленитесь и ознакомьтесь со списком возможных инструментов django. Огромное количество полезных и удобных возможностей уже реализовано и придумано за вас, просто берите и пользуйтесь.

Авторизация. Django-allauth

В прошлом проекте мы использовали чистый функционал django для реализации механизма аутентификации пользователей, но на практике для этого используются вспомогательные библиотеки. Наиболее часто используемая для этих целей библиотека django-allauth. Сразу приступим к установке и разберемся что же хорошего в этой библиотеке.
Команда для установки:
pip install django-allauth

Далее пойдем по мануалу и внесем необходимые изменения в настройки проекта.

pylibrary/settings.py
INSTALLED_APPS = [
    ...
    'django.contrib.auth',
    'django.contrib.sites',
    'django.contrib.messages',
    ...

    # UTILS
    ...
    'allauth',
    'allauth.account',
    'allauth.socialaccount',
    ...
]
AUTHENTICATION_BACKENDS = [
    # Needed to login by username in Django admin, regardless of `allauth`
    'django.contrib.auth.backends.ModelBackend',

    # `allauth` specific authentication methods, such as login by e-mail
    'allauth.account.auth_backends.AuthenticationBackend',
]
SITE_ID = 1
pylibrary/urls.py
urlpatterns = [
    path('admin/', admin.site.urls),
    path('', include('book.urls')),
    path('ckeditor/', include('ckeditor_uploader.urls')),
    path('accounts/', include('allauth.urls')),
]

Первым делом разместим три приложения поставляемых с django-allauth и также базовое приложение 'django.contrib.sites', которого ранее в нашем проекте не было. С приложением django.contrib.sites взаимодействую многие инструменты, поэтому не будет лишним взять за правило ставить это приложение в каждый ваш новый проект. Это приложение для поддержки нескольких сайтов и оно требует обязательной настройки SITE_ID, эта настройка буквально означает номер сайта, установим ее, например, в единицу. Приложения 'django.contrib.auth' и 'django.contrib.messages' уже были предустановлены в нашем приложении и скорее всего у вас тоже, но их наличие для работы django-allauth также необходимы.

Также добавим бэкенды необходимые для функционирования приложения и новый путь в главном файле urls.py.

Скорее всего если вы теперь захотите произвести миграции вы столкнетесь с ошибкой, решение которой я если честно так и не нагуглил. В итоге я просто создал в postgresql новую базу, подключил ее к проекту и произвел новую миграцию, а после скопировал информацию из старой базы в новую. Это еще раз доказывает, что предварительная подготовка к проекту очень важна. Важно понимать какие приложения вам могут пригодиться и обязательно нужно продумывать архитектуру баз данных, перестройка баз данных большого проекта может оказаться очень непростой задачей. Поэтому лучше сначала не ставить себе цели писать большие и многофункциональные проекты, а начинать с более простых, но зато каждый раз с нуля, набить шишки на собственных проектах это важный и бесценный опыт во время вашего обучения.

У django-allauth отличная страница конфигурации, тут понятным образом описаны все возможные настройки, которые могут вам пригодится. Воспользуемся некоторыми из них.

pylibrary/settings.py
ACCOUNT_AUTHENTICATION_METHOD = "username_email"
ACCOUNT_EMAIL_CONFIRMATION_EXPIRE_DAYS = 1
ACCOUNT_USERNAME_MIN_LENGTH = 1
LOGIN_REDIRECT_URL = '/'
ACCOUNT_LOGOUT_REDIRECT_URL = '/'
ACCOUNT_LOGIN_ATTEMPTS_LIMIT = 500

EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend'

Из названия настроек понятно их предназначение, но давайте пройдемся сверху вниз.
ACCOUNT_AUTHENTICATION_METHOD в этой настройке записывается какой метод аутентификации можно использовать: по email, по логину или по тому и тому, как в нашем случае.
ACCOUNT_EMAIL_ CONFIRMATION_EXPIRE_DAYS - время, за которое пользователь должен подтвердить регистрацию по email.
ACCOUNT_USERNAME_MIN_LENGTH минимальная длина логина.
LOGIN_REDIRECT_URL и ACCOUNT_LOGOUT_REDIRECT_URL редирект в случае входа и выхода соответственно, на главную страницу в нашем случае.
ACCOUNT_LOGIN_ATTEMPTS_LIMIT установил для себя во время тестирования, допустимое количество неудачных попыток авторизации, после израсходования этого количества пользователь будет получать предупреждение.

EMAIL_BACKEND = 'django.core.mail.backends. console.EmailBackend'. Настройка нужная для имитации отправки почты пользователям в консоли. Подключать реальные ящики по SMTP мы пока не станем, консольной имитации достаточно для понимания как будет работать функционал почты.

По адресу '(название виртуального окружения)/lib/python(версия)/site-packages/allauth' находится приложение django-allauth, менять что-то внутри приложения напрямую по этому пути категорически не рекомендуется, но мы можем переопределить некоторое поведение приложения под наши нужды.

И займемся мы в первую очередь переопределением шаблонов. Для этого скопируем папу account, находящуюся по адресу '(название виртуального окружения)/lib/python(версия)/site-packages/allauth/templates', после этого создадим папку templates в корне проекта, именно в корне, не в конкретном приложении, и поместим папку account в эту папку templates. В папке pylibrary/templates/account теперь хранятся все шаблоны каждой из страниц аутентификации.

pylibrary/templates/account/base.html
{% extends 'book/base.html' %}
<!DOCTYPE html>
<html>
  <head>
    <title>{% block head_title %}{% endblock %}</title>
...

Для того, чтобы унаследоваться от нашего базового шаблона пропишем это наследование в начале базового шаблона аутентификации.

Но если теперь попробовать перейти по любому адресу мы пока не увидим никаких изменений, потому что мы пока не сказали приложению, что мы хотим использовать свои шаблоны взамен шаблонов приложения.

pylibrary/settings.py
TEMPLATES = [
    {
        'BACKEND': 'django.template.backends.django.DjangoTemplates',
        'DIRS': [os.path.join(BASE_DIR, 'templates')],
        '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',
            ],
        },
    },
]

В настройке TEMPLATES в ключе 'DIRS' нам нужно указать путь до папки с шаблонами. Теперь переопределение шаблонов будет работать. Давайте отредактируем, например, шаблон с регистрацией.

pylibrary/templates/account/signup.html
{% extends "account/base.html" %}

{% load i18n %}

{% block head_title %}{% trans "Signup" %}{% endblock %}

{% block content %}
<h1>{% trans "Регистрация" %}</h1>

<p>{% blocktrans %}Уже зарегистрированы? Авторизуйтесь <a href="{{ login_url }}" class="link">Авторизация</a>.{% endblocktrans %}</p>

<form class="signup" id="signup_form" method="post" action="{% url 'account_signup' %}">
    {% csrf_token %}
    <label class="form-label">Имя пользователя</label>
    <section>{{ form.username }}</section>
    <br>
    <label class="form-label">email</label>
    <section>{{ form.email }}</section>
    <br>
    <label class="form-label">Пароль</label>
    <section>{{ form.password1 }}</section>
    <br>
    <label class="form-label">Подтвердите пароль</label>
    <section>{{ form.password2 }}</section>
    <br>
    {% if form.errors %}
        {% for field in form %}
            {% for error in field.errors %}
            <strong>{{ error }}</strong>
            {% endfor %}
        {% endfor %}
            {% for error in form.non_field_errors %}
            <strong>{{ error }}</strong>
            {% endfor %}
    {% endif %}
    {% if redirect_field_value %}
    <input type="hidden" name="{{ redirect_field_name }}" value="{{ redirect_field_value }}"/>
    {% endif %}
    <br>
    <button type="submit" class="btn btn-primary">{% trans "зарегистрироваться" %}</button>
</form>

{% endblock %}

Выведем каждое поле по отдельности и немного застилизуем его, поменяем текст в нужных местах на русский и добавим свой блок с выводом ошибок. Также не забудем про стили для некоторых элементов, поскольку этот шаблон унаследован от 'account/base.html', а тот в свою очередь от 'book/base.html' наш css дотягивается и до этого шаблона.

Но для знакомства с тем как переопределять формы под свои нужды давайте заменим текст ошибок, которые могут возникать при авторизации.

venv/lib/python3.8/site-packages/allauth/account/forms.py
class LoginForm(forms.Form):

    ...
    error_messages = {
        "account_inactive": _("This account is currently inactive."),
        "email_password_mismatch": _(
            "The e-mail address and/or password you specified are not correct."
        ),
        "username_password_mismatch": _(
            "The username and/or password you specified are not correct."
        ),
    }
    ...

В головном файле forms.py приложения allauth найдем форму для авторизации и в ней нас интересует переменная error_messages, в ней как раз и написан текст ошибок связанных с авторизацией. Как нам его переопределить? Как упоминалось выше тут это делать бесполезно. Да в вашем текущем проекте это повлияет на поведение приложения, но если вашим приложением захотят воспользоваться другие люди и скачают его, к примеру, с github, а после установят все необходимые вспомогательные инструменты для вашего приложения, то как вы понимаете, скачаются они в чистом виде, без внесенных вами изменений.

Для переопределения форм у авторов приложения есть инструкция, посмотрим на примере формы авторизации.

book/forms.py
from allauth.account.forms import LoginForm


class MyCustomLoginForm(LoginForm):
    error_messages = {
        "account_inactive": "Этот аккаунт заблокирован",
        "email_password_mismatch":
            "Неверный email или пароль"
        ,
        "username_password_mismatch":
            "Неверный логин или пароль",
    }

    def login(self, *args, **kwargs):
        # Add your own processing here.

        # You must return the original result.
        return super(MyCustomLoginForm, self).login(*args, **kwargs)
pylibrary/settings.py
ACCOUNT_FORMS = {
    'login': 'book.forms.MyCustomLoginForm',
}

Для этого создадим forms.py в приложении book и добавим в нее следующий код, поскольку нас интересует переменная error_messages, то переопределим в этой форме мы только ее, весь остальной фрагмент взять полностью из документации. И после этого в settings.py нм нужно добавить настройку ACCOUNT_FORMS = , куда будем помещать формы, которые мы изменяли.

Вот таким нехитрым способом мы можем переопределять формы приложения.

Вы наверное обратили внимание на кнопки авторизации и регистрации. Давайте посмотрим как в шаблоне я это разместил.

book/base.html
<nav class="navbar navbar-expand-lg navbar-light bg-light">
    <div class="container-fluid">
        <a class="navbar-brand ttl" href="{% url 'home' %}">Библиотека</a>
    </div>
    <div class="register_nav">
        {% if user.is_authenticated %}
        <span>Вы вошли как: <b>{{ user }}</b></span>
        <a class="navbar-brand" href="{% url 'account_logout' %}">Выйти</a>
        {% else %}
        <a class="navbar-brand auth_nav" href="{% url 'account_login' %}">Авторизация</a><a class="navbar-brand auth_nav" href="{% url 'account_signup' %}">Регистрация</a>
        {% endif %}
    </div>
</nav>

В случае, когда пользователь авторизован будем выводить имя пользователя и ссылку выхода, а в противном случае ссылки на авторизацию и регистрацию.

Давайте попробуем сымитировать регистрацию пользователя.

После удачной регистрации нас сразу перенаправляет на домашнюю страницу в качестве авторизованного пользователя. В консоли в это время происходит имитация отправки письма со ссылкой для подтверждения почты. При клике на эту ссылку нас перенаправляет на страницу подтверждения email. Согласитесь, получилось неплохо и при этом мы почти ничего не изменяли, весь этот функционал (и не только этот) мы получили просто установкой библиотеки django-allauth.

django-allauth. Авторизация через GitHub

Еще одна особенность django-allauth это авторизация через социальные сети. На первой странице документации есть огромное количество возможных для подключения авторизационных провайдеров. Давайте сделаем авторизацию через GitHub, не зря же мы установили с вами 'allauth.socialaccount'. Для этого нам нужно подключить в проект еще одно приложение
'allauth.socialaccount.providers.github'
После этого следует обратиться к документации подключения авторизации через github. В этой документации есть ссылка на создание приложения github.

Данные для заполнения достаточно очевидны.

После регистрации приложения нам становится доступен Client id и Client secrets, эти данные понадобятся нам.

Перейдем а админку приложения и воспользуемся своими только что полученными данными для добавления нового социального приложения. Ранее мы с вами зарегистрировали приложение 'django.contrib.sites', в нем мы как раз и зарегистрируем site и добавим его как на скриншоте.

Приложение готово к использованию, давайте только немного поработаем над шаблонами.

pylibrary/templates/account/login.html
{% extends "account/base.html" %}

{% load i18n %}
{% load account socialaccount %}

{% block head_title %}{% trans "Sign In" %}{% endblock %}

{% block content %}

<h1>{% trans "Авторизация" %}</h1>

{% get_providers as socialaccount_providers %}

{% if socialaccount_providers %}
{% blocktrans %}Вы можете авторизоваться через GitHub{% endblocktrans %}
<div class="githublog">
    <a href="{% provider_login_url "github" method="oauth2" %}" class="center">in</a>
</div><br>
{% blocktrans %}Либо{% endblocktrans %}

{% include "socialaccount/snippets/login_extra.html" %}

{% else %}
<p>{% blocktrans %}Если вы у вас еще нет аккаунта вы можете
...
book/style.css
.githublog {
    width: 60px;
    height: 60px;
    border: 1px solid black;
    border-radius: 100%;
    background-image: url(git_icon.svg);
    display: flex;
    margin-left: 8%;
}

.center {
    margin: auto;
    color: black;
    font-weight: 900;
}

login.html отредактируем следующим образом, поскольку кроме авторизации через github я не планирую добавлять ничего то из блока {% if socialaccount_providers %} я удалил все содержимое и заменил его своим. Ссылка вида
<a href="{% provider_login_url "(название приложения)" method="oauth2" %}">(название приложения)</a>
взята также из документации.
Помимо изменения login.html добавим немного стилизации для github авторизации. css фрагмент самый обычный, заострять внимание тут не на чем. Иконку скачал с какой-то первой ссылки из интернета, их огромное количество в бесплатном доступе, не забывайте использовать формат svg для иконок, этот формат всегда выглядит выгодней.

Посмотрим, что из этого вышло.

При нажатии на in мы попадаем на страницу подтверждения, а от туда на страницу авторизации.

После авторизации нас перенаправляет на стартовую страничку. Теперь у нас на сайте есть работающий метод авторизации через github.

Если вы будете использовать данную авторизацию для опубликованного сайта, то просто замените локальный адрес на домен сайта. И не забудьте добавить ваш опубликованный сайт в таблицу Сайты административной панели.

Модель комментариев

Давайте реализуем возможность оставлять комментарии к книгам. Для этого конечно в первую очередь перейдем в models.py для создания модели комментариев.

book/models.py
class Review(models.Model):
    content = models.TextField(max_length=3000)
    book = models.ForeignKey(Book, on_delete=models.CASCADE)

    def __str__(self):
        return f"отзыв к {self.book}"

Реализуем ее пока таким образом, у нас будет поле для самого комментария с ограничением в 3000 символов и завязывать комментарий мы будем на конкретной книге с условием удаления всех комментариев при удалении книги.

book/views.py
class AddReview(View):

    def post(self, request, pk):
        form = ReviewForm(request.POST)
        book = Book.objects.get(id=pk)
        if form.is_valid():
            form = form.save(commit=False)
            form.book = book
            form.save()
        return redirect(book.get_absolute_url())
book/forms.py
class ReviewForm(forms.ModelForm):
    class Meta:
        model = Review
        fields = ('content', )
book/urls.py
urlpatterns = [
    path('', views.HomeView.as_view(), name='home'),
    path('book/<slug:book_slug>', views.BookDetailView.as_view(), name='book_info'),
    path('category/<slug:category_slug>', views.CategoryInfoView.as_view(), name='category_info'),
    path('review/<int:pk>', views.AddReview.as_view(), name='add_review'),
]

Для представления унаследуемся от базового View. Если 'провалиться' в сам этот класс мы увидим поддерживаемые http методы

class View:
    http_method_names = ['get', 'post', 'put', 'patch', 'delete', 'head', 'options', 'trace']

Воспользуемся мы методом post, создав одноименную функцию, принимать он будет экземпляр класса, request и pk, который будет хранить id книги. Сразу создадим связанную с комментарием форму. Данные переданные в эту форму мы будем сохранять в переменную form, а в переменную book заберем книгу, на странице которой мы в данный момент находимся. Методом is_valid() будем проверять верность переданных в форму данных и в случае True будем сохранять в БД комментарий для книги. Параметр commit=False позволяет не окончательно сохранить переданные данные, а провести после этого save() еще некоторые изменения перед окончательным сохранением. Строчкой form.book = book мы завязываем комментарий на нужную книгу и после уже делаем окончательное сохранение. Ну и возвращать нас эта функция после отправки формы будет на страницу книги, на которой мы находимся. В urls.py добавим новый путь, где будем переданный pk превращать в int.

book/book_info.html
{% extends 'book/base.html' %}

{% block title %}{{ title }}{% endblock %}

{% block content %}

    <h2 class="text-center">{{ books.title }}</h2>
    <p class="text-center">{{ books.category }}</p>
    {% for author in books.authors.all %}
        <h5 class="text-end">{{ author.name }}</h5>
    {% endfor %}
    <p class="px-5 mt-5">{{ books.description|safe }}</p>
    <p class="text-end fw-bold">{{ books.year }}</p>
    
    <h3>Оставить отзыв</h3>
        {% if user.is_authenticated %}
        <form action="{% url 'add_review' object.id %}" method="post">
            {% csrf_token %}
            <textarea name="content"></textarea>
            <br><button class="btn btn-primary" type="submit">
                Оставить отзыв
            </button>
        </form>
        {% else %}
            <p>Для отправки отзыва необходимо авторизоваться</p>
        {% endif %}
    <br><h3>отзывы</h3>
    {% if object.review_set.all %}
    {% for review in object.review_set.all %}
    <figure class="review">
      <blockquote class="blockquote">
        <p>{{ review.content }}</p>
      </blockquote>
    </figure>
    {% endfor %}
    {% else %}
        <p>Здесь пока ничего нет</p>
    {% endif %}

{% endblock %}

Что касается шаблона, раз отзывы относится к книгам, то и разместим мы этот фрагмент в book_info.html. Разделим секцию связанную с комментариями на два блока, первый - форма для отправки отзыва, второй - вывод комментариев на странице. В первом блоке будем делать проверку на авторизованность.

Выглядеть это будет так. Также проверку мы будем делать и на наличие комментариев, в случае наличия мы их выводим, а в противном видим то же, что на скриншоте.

Касаемо самой формы отправки отзыва, поместим ее в тег form с method='post'. action этой формы будет содержать тег url, где адресом будет имя пути из urls.py, а id будем забирать у object. object в данном случае будет хранить модель Book. Таким образом мы связали форму с view, при нажатии на кнопку срабатывает метод POST, данные переданные в форму передаются в переменную form класса AddReview(). object.id также в момент нажатия на кнопку забирает в себя id книги, на странице которой мы находимся и передает это значение в параметр pk функции post(), но предварительно оно преобразуется к типу int внутри функции path. Далее в переменную book мы забираем из БД ту книгу, id которой соответствует полученному значению pk. Следующее действие - проверка на валидность, валидными данными в нашем случае считается любой набор символов в количестве <= 3000. После получения True весь процесс мы уже обсудили. Для вывода поля content воспользуемся тегом textarea, в параметре name которого укажем название поля из формы.

Теперь разберемся с выводом комментариев. Поскольку object хранит модель Book, а отзывы к ней привязаны, мы можем обратиться к ним через книгу. Для обращения к дочерним моделям используется запись (название дочерней модели)_set.all. В цикле будем перебирать все связанные отзывы и поочередно выводить их содержимое через .content.

Давайте авторизуемся, добавим несколько комментариев и разлогинимся. Теперь в независимости от пользователя мы видим отзывы.

Не хватает только имени автора, оставившего отзыв, давайте это поправим.

book/models.py
class Review(models.Model):
    content = models.TextField(max_length=3000)
    book = models.ForeignKey(Book, on_delete=models.CASCADE)
    user = models.ForeignKey(User, on_delete=models.CASCADE, blank=True, null=True)

    def __str__(self):
        return f"отзыв к {self.book}"

Для этого добавим в модель отзыва завязку на пользователе по аналогии с книгой, но поскольку наша модель уже содержит записи, то для добавления ForeignKey установим blank=True, null=True и вручную выберем авторов комментария в админке.

Таким образом у отзывов, которые уже есть в БД мы выберем авторов, но у новых отзывов автор будет ровняться None, поправить этот момент нужно в нашем AddReview.

book/views.py
class AddReview(View):

    def post(self, request, pk):
        form = ReviewForm(request.POST)
        book = Book.objects.get(id=pk)
        user = User.objects.get(username=request.user)
        if form.is_valid():
            form = form.save(commit=False)
            form.book = book
            form.user = user
            form.save()
        return redirect(book.get_absolute_url())

Будем забирать в переменную user того пользователя, имя которого будет равняться имени пользователя из запроса и затем сохранять этого пользователя в БД Review.

book/book_info.html
<br><h4>отзывы</h4><br>
{% if object.review_set.all %}
{% for review in object.review_set.all %}
<figure class="review">
  <blockquote class="blockquote">
    <h6>{{ review.content }}</h6>
  </blockquote>
    <figcaption class="blockquote-footer text-end">
        <cite title="Source Title">{{ review.user }}</cite>
    </figcaption>
</figure>
{% endfor %}
{% else %}
    <p>Здесь пока ничего нет</p>
{% endif %}

И осталось только немного откорректировать шаблон вывода отзывов.

Вот что мы имеем на данный момент.

CreateView, UpdateView, DeleteView

Рассмотрим еще три View, которые достаточно тесто друг с другом связаны. Давайте добавим возможность добавлять новую книгу, редактировать ее и удалять, похожий функционал мы реализовывали, когда писали третий сайт первого блока по django. Но тогда мы использовали функции, сейчас же воспользуемся классами. Начнем с CreateView.

book/views.py
class AddCrateView(CreateView):
    model = Book
    template_name = 'book/add_book.html'
    fields = ('title', 'description', 'year', 'category', 'authors')
    success_url = '/'
book/urls.py
urlpatterns = [
    ...
    path('create/', views.AddCrateView.as_view(), name='create'),
    ...
]
book/add_book.html
{% extends 'book/base.html' %}

{% block title %}Создать{% endblock %}

{% block content %}
    <form method="post">
        {% csrf_token %}
        <label class="form-label">Название</label>
        <section>{{ form.title }}</section>
        <label class="form-label">Описание</label>
        <section>{{ form.description }}</section>
        <label class="form-label">Год</label>
        <section>{{ form.year }}</section>
        <label class="form-label">Категория</label>
        <section>{{ form.category }}</section>
        <label class="form-label">Авторы</label>
        <section>{{ form.authors }}</section>
        <button class="btn btn-primary" type="submit">
                Сохранить
        </button>
    </form>
{% endblock %}

Параметры CreateView похожи на прочие View, мы также берем модель и имя шаблона, если хотим его задавать явно. Что вообще делает CreateView? CreateView формирует форму на основе модели, в параметре title указываем поля модели на основе, которых сформируются поля формы. Параметр success_url содержит адрес куда мы возвратимся в случае успешной отправленной формы. CreateView в качестве контекста возвращает form, к параметрам которой можно обратиться через точку или вывести целиком просто написав {{ form }}. И традиционно не забудьте добавить url для нового view.
На данный момент по адресу localhost/create/ уже находится форма, которую можно заполнить и которая даже сохранить переданные данные в базу данных. Эта запись появится среди прочих, но ее страницу не получится перейти. При попытке перехода на новую запись мы будем пытаться перейти по адресу localhost/book/None, где вместо None должен быть slug новой книги. Мы просим пользователя заполнять все поля кроме slug, мы хотим чтобы slug формировался автоматически. Мы писали в admin.py настройку для автоформирования slug, но она работает только когда мы вводим значение для поля, на основе которого формируется slug, непосредственно как запись в самой БД. А когда речь идет о форме, с которой мы забираем данные и отдаем их в БД, то прямого наследования одного поля от другого в данном моменте программы у нас нет. Мы сразу получили готовое значение для поля, на основе которого формируется slug и сразу в таком виде передали данные в БД, минуя возможности prepopulated_fields.
Избежать этого можно несколькими способами, мы воспользуемся для этого библиотекой django-autoslug, которая использует в своей реализации функцию django - slugify(). После установки достаточно заменить поле slug в модели Book.

book/models.py
...
from autoslug import AutoSlugField


class Book(models.Model):
    title = models.CharField(max_length=150)
    slug = AutoSlugField(populate_from='title', unique=True, db_index=True, null=True)
    ...

Заменим models.SlugField на AutoSlugField, поле на основе которого формировать slug задается в параметре populate_from=. Теперь slug сформируется даже от переданных через форму данных и сохранится в БД. Теперь на страницу каждой страницы возможно перейти.

book/views.py
class BookUpdateView(UpdateView):
    model = Book
    template_name = 'book/add_book.html'
    fields = ('title', 'description', 'year', 'category', 'authors')
    success_url = '/'


class BookDeleteView(DeleteView):
    model = Book
    template_name = 'book/delete_book.html'
    success_url = '/'
book/urls.py
urlpatterns = [
    ...
    path('update/<int:pk>', views.BookUpdateView.as_view(), name='update'),
    path('delete/<int:pk>', views.BookDeleteView.as_view(), name='delete'),
    ...
]
book/delete_book.html
{% extends 'book/base.html' %}

{% block title %}Удалить{% endblock %}

{% block content %}
    <form method="post">
        {% csrf_token %}
        <h4>{{ object }}</h4>
        <input class="btn btn-danger" type="submit" value="Удалить запись?">
    </form>
{% endblock %}

UpdateView работает идентично CreateView, за тем исключением, что UpdateView не добавляет новую запись, а редактирует имеющуюся. Мы ссылаемся на ту же модель и заимствует от нее те же поля и шаблон для этих View нам подойдет одинаковый. С DeleteView есть некоторые особенности, этому View вообще можно не задавать шаблон, а можно сделать как сделал я и добавить промежуточный шаблон с удалением. В urls.py пути необходимо указать с идентификатором записи, которую мы хотим удалить или отредактировать.

book/book_info.html
...

{% block content %}
    ...
    {% if user.is_authenticated %}
    <button class="btn btn-warning mb-5" type="submit">
    <a href="{% url 'update' object.id %}">Редактировать запись</a>
    </button>
    <button class="btn btn-danger mb-5" type="submit">
    <a href="{% url 'delete' object.id %}">Удалить запись</a>
    </button>
    <h4>Оставить отзыв</h4>
        <form action="{% url 'add_review' object.id %}" method="post">
            {% csrf_token %}
    ...
book/base.html
...
<div class="register_nav">
    {% if user.is_authenticated %}
    <div class="mx-5">
        <a class="navbar-brand" href="{% url 'create' %}">Добавить книгу</a>
    </div>
    <span>Вы вошли как: <b>{{ user }}</b></span>
    ...

Осталось добавить в шаблоны кнопки добавления, редактирования и удаления записей. Добавление добавим в base.html, при условии, что пользователь авторизован. А кнопки редактирования и удаления записей добавим на личную страницу каждой книги, передавая в теге url параметр object.id мы будем редактировать и удалять именно ту книгу, на странице которой мы находимся. Посмотрим что у нас получилось.

Так выглядит страница добавления новой книги.

Так теперь выглядит страница каждой книги.

И редактирование с удалением. Все работает, но выглядит это на данный момент не очень приятно. Можно стилизовать каждое поле вручную через css, а можно через виджеты. Воспользуемся вторым вариантом, ранее мы виджетами не пользовались, но знать о них нужно. Как вы помните, набор полей мы задавали явно во View, но наиболее распространенной практикой считается создание формы и добавления этой формы во View, давайте сделаем также.

book/forms.py
class AddForm(forms.ModelForm):

    title = forms.CharField(label='Название', widget=forms.TextInput(
        attrs={'class': 'form-control'}))

    description = forms.CharField(label='Описание (поддерживает html разметку)', widget=forms.Textarea(
        attrs={'class': 'form-control'}))

    year = forms.IntegerField(label='Год', widget=forms.NumberInput(
        attrs={'class': 'form-control'}))

    category = forms.ModelChoiceField(
            queryset=Category.objects.all(),
            label='Категория',
            widget=forms.Select(attrs={'class': 'form-control'})
    )

    authors = forms.ModelMultipleChoiceField(
        queryset=Author.objects.all(),
        label='Авторы (зажмите ctrl для множественного выбора)',
        widget=forms.SelectMultiple(attrs={'class': 'form-control'})
    )

    class Meta:
        model = Book
        fields = ('title', 'description', 'year', 'category', 'authors')
book/add_book.html
{% extends 'book/base.html' %}

{% block title %}Создать{% endblock %}

{% block content %}
    <form method="post">
        {% csrf_token %}
        {{ form.as_p }}
        <button class="btn btn-primary" type="submit">
                Сохранить
        </button>
    </form>
{% endblock %}
book/views.py
class AddCrateView(CreateView):
    model = Book
    template_name = 'book/add_book.html'
    form_class = AddForm
    success_url = '/'


class BookUpdateView(UpdateView):
    model = Book
    template_name = 'book/add_book.html'
    form_class = AddForm
    success_url = '/'

В forms.py создадим новую форму и каждое поле создадим вручную. Типы полей в формах очень похожи на типы полей в моделях, CharField, SlugField, BooleanFiled и прочие, исключением являются поля типов связей. Аналогом models.ForeignKey является forms.ModelChoiceField, а models.ManyToManyField - forms.ModelMultipleChoiceField. У полей форм есть свои параметры, например, label и widget, которыми мы воспользуемся в каждом из полей. Типы виджетов тоже разнообразны, но они повторяют тип поля, так TextInput виджет для ввода небольшого количества теста, а Textarea для большого и так далее. У виджета можно воспользоваться параметром attrs, в котором явно укажем css стиль для этого поля, в данном случае воспользуемся стандартным классом bootstrap для полей ввода. С ModelChoiceField и ModelMultipleChoiceField есть еще одна особенность, параметр queryset, поскольку эти поля выбирают данные из связанных моделей, то и забрать записи этих моделей нам необходимо.
В add_book.html теперь можем вывести форму как {{ form.as_p }}, где as_p как вы помните, разбивает поля формы тегами <p>.
А во views.py параметр fields заменим на form_class = , в котором укажем имя формы.

Посмотрим на результат на примере редактирования записи. Стало посимпатичней.

Но остался еще один момент, на который следует обратить внимание. Сейчас любой пользователь может редактировать и удалять любую запись на сайте, конечно, такое поведение нам не нужно. Пусть каждый пользователь может редактировать и удалять только те книги, которые он сам и добавил. Для этого в первую очередь нам необходимо связать книги с пользователями.

book/models.py
...
class Book(models.Model):
    ...
    user = models.ForeignKey(User, on_delete=models.CASCADE, blank=True, null=True)

...
book/views.py
class AddCrateView(CreateView):
    model = Book
    template_name = 'book/add_book.html'
    form_class = AddForm
    success_url = '/'

    def form_valid(self, form):
        form.instance.user = self.request.user
        return super(AddCrateView, self).form_valid(form)

После связи модели Book с User надо сделать так, чтобы когда пользователь добавляет новую книгу через форму на сайте, эта книга привязывалась бы к этому пользователю. Для этого переопределим метод form_valid. Поле в модели Book, которое хранит информацию о пользователе называется User, поэтому мы используем запись form.instance.user, так мы обращаемся к полю user и записываем в него текущего авторизованного пользователя записью self.request.user. С этим разобрались, осталось еще несколько моментов.

book/views.py
class BookUpdateView(UpdateView):
    model = Book
    template_name = 'book/add_book.html'
    form_class = AddForm
    success_url = '/'

    def get_queryset(self):
        queryset = Book.objects.filter(user=self.request.user)
        return queryset


class BookDeleteView(DeleteView):
    model = Book
    template_name = 'book/delete_book.html'
    success_url = '/'

    def get_queryset(self):
        queryset = Book.objects.filter(user=self.request.user)
        return queryset
book/book_info.html
...
{% if user.is_authenticated %}
        {% if object.user == request.user %}
            <button class="btn btn-warning mb-5" type="submit">
            <a href="{% url 'update' object.id %}">Редактировать запись</a>
            </button>
            <button class="btn btn-danger mb-5" type="submit">
            <a href="{% url 'delete' object.id %}">Удалить запись</a>
            </button>
        {% endif %}
<h4>Оставить отзыв</h4>
...

В первую очередь переопределим метод get_queryset для UpdateView и DeleteView, этим действием мы открываем доступ для редактирования и удаления только тех записей, значение поля user которых равно значению user из request'а. Уже сейчас, пока мы еще не скрыли кнопки на тех страницах где они не нужны, кнопки не связанных с пользователем книг будут возвращать нам 404 ошибку. Мы уже обезопасили записи, но видеть кнопки редактирования и удаления записей на страницах тех книг, где мы не можем применить эти действия, нам ни к чему. Для того чтобы их скрыть воспользуемся тегом if где будем сравнивать user'а книги и user'а request'а и в случае совпадения отрисовывать две этих кнопки.

Таким образом, в первой книге пользователь admin является связан с записью и мы можем ее редактировать, в случае второй книги с ней связан другой пользователь и эти кнопки у нас не отображаются. Весь код проекта вы традиционно можете найти у меня на github (проект по ссылке содержит некоторые дополнительные изменения, о которых говорится в блоках 'Django. ORM' и 'Django и интернет)'.

Что дальше?

Мы разобрали еще ряд важных моментов, что-то повторили, что-то выучили и освоили с нуля, но определенно мы стали владеть django значительно лучше. На основе этого сайта я бы хотел разобрать еще несколько моментов, но которые будут вынесены в отдельные блоки. Первый - ORM, в этом блоке разберемся с возможностями ORM, база для этого у нас уже есть. Второй и третий - развертывание сайта на реальном хостинге, один блок посвящен бесплатным хостингам, второй - платным. Четвертый - работа интернета, это относится не именно к Django, а в целом является важным знанием для разработки, особенно на серверной стороне и поскольку из серверного у нас сейчас Django, то и вынесем этот блок в эту категорию и в связи с этим некоторые вопросы актуальные именно для Django в этом блоке тоже будут. Но и на этом знакомство с Django не закончится, как минимум у нас остался DRF, с которым думаю мы будем разбираться на примере еще одного сайта, который предварительно придется нам с вами написать. Так что к этому моменту можно сказать, что вы знаете Django, но насколько хорошо вы его знаете - большой вопрос.

Для отправки комментария необходимо авторизоваться



Комментарии

Здесь пока ничего нет...