В качестве хобби, я решил открыть для себя мир веб-разработки, попутно освещая свои успехи, а может и неудачи на пути к просветлению…

В настоящее время я публично описываю процесс создания веб-сайта с нуля на Django и Bootstrap, где задача разработать такие приложения как: аккаунты, вопросы, статьи и книги.

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

Создание веб-сайта с нуля на Django и Bootstrap. Книги. Модели и администрирование

Сергей Серов

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

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

Функциональность

  • Индексная страница: вывод списка книг;
  • Статус книги: в процессе или окончена;
  • Страница книги: полная информация о книги включая список глав;
  • Визуальный редактор для написания текста главы;
  • Статус главы: черновик, запланирована или опубликована;
  • Сортировка глав, автоматическая установка позиций, но с возможностью ручного изменения;
  • Страница главы: умная и автоматическая разбивка текста главы на страницы;
  • Количество просмотров главы, для внутреннего использования;
  • Ссылки на предыдущую и следующую главы;
  • Ссылки на книги и главы основываются на их заголовках;
  • Смена заголовков книг и глав без вымирания ссылок;
  • Ленты обновлений книг и глав;
  • Список книг и глав для карты сайта;
  • Администрирование.

Создание

В командной строке создаем приложение с названием books:

python manage.py startapp books

Модели

Переходим в директорию приложения и заполняем файл models.py:

import re

from django.db import models
from django.urls import reverse
from django.utils import timezone
from django.conf import settings

from core.utils import slugify, strip_tags


class PublicBooksManager(models.Manager):
    def get_queryset(self):
        return super(PublicBooksManager, self).get_queryset().filter(published__lte=timezone.now())


class Book(models.Model):
    title = models.CharField('название', max_length=255)
    slug = models.SlugField('слаг', max_length=255, editable=False)
    slogan = models.CharField('слоган', max_length=255)
    description = models.TextField('аннотация')
    summary = models.CharField('резюме', max_length=255, editable=False)
    image = models.ImageField('обложка', upload_to='books/')
    redirect = models.URLField('редирект', max_length=255, blank=True)
    completed = models.DateTimeField('завершена', blank=True, null=True)
    published = models.DateTimeField('опубликована', blank=True, null=True, db_index=True)
    created = models.DateTimeField('создана', auto_now_add=True)
    updated = models.DateTimeField('обновлена', auto_now=True)

    objects = models.Manager()
    public = PublicBooksManager()

    class Meta:
        verbose_name = 'книга'
        verbose_name_plural = 'книги'
        ordering = ['-pk']

    def __str__(self):
        return self.title

    def save(self, *args, **kwargs):
        self.slug = slugify(self.title, 255)
        self.summary = strip_tags(self.description, 255)
        super(Book, self).save(*args, **kwargs)

    def get_absolute_url(self):
        return reverse('books:book', args=[self.slug, self.pk])

    def get_admin_url(self):
        return reverse('admin:{}_{}_change'.format(self._meta.app_label, self._meta.model_name), args=[self.pk])

    def chapters(self):
        return Chapter.public.filter(book_id=self.pk).select_related()


class PublicChaptersManager(models.Manager):
    def get_queryset(self):
        return (super(PublicChaptersManager, self).get_queryset().
                filter(book__published__lte=timezone.now(), published__lte=timezone.now()))


class Chapter(models.Model):
    page_separator = '<!-- separator -->\n'

    book = models.ForeignKey(Book, verbose_name='книга')
    title = models.CharField('заголовок', max_length=255)
    slug = models.SlugField('слаг', max_length=255, editable=False)
    text = models.TextField('текст')
    summary = models.CharField('резюме', max_length=255, editable=False)
    position = models.PositiveSmallIntegerField('позиция', default=0, db_index=True)
    n_views = models.PositiveIntegerField('количество просмотров', default=0, editable=False)
    published = models.DateTimeField('опубликована', blank=True, null=True, db_index=True)
    created = models.DateTimeField('создана', auto_now_add=True)
    updated = models.DateTimeField('обновлена', auto_now=True)

    objects = models.Manager()
    public = PublicChaptersManager()

    class Meta:
        verbose_name = 'глава'
        verbose_name_plural = 'главы'
        ordering = ['position']

    def __str__(self):
        return self.title

    def save(self, *args, **kwargs):
        self.slug = slugify(self.title, 255)
        self.summary = strip_tags(self.text, 255)
        # вставляем разделитель страниц
        self.insert_page_separator()
        # только опубликованные главы имеют порядок
        if not self.published:
            self.position = 0
        # если позиция не указана, производим автоматический расчет
        next_p = Chapter.objects.filter(book_id=self.book_id, published__isnull=False).exclude(pk=self.pk).count() + 1
        if self.published and not self.position:
            self.position = next_p
        # скорректируем позицию главы, если пользователь по какой-либо причине смог завысить ее номер
        if self.position > next_p:
            self.position = next_p
        # если это необходимо поправляем позиции у остальных глав
        self.update_positions()
        super(Chapter, self).save(*args, **kwargs)

    def delete(self, *args, **kwargs):
        self.position = 0
        self.update_positions()
        super(Chapter, self).delete(*args, **kwargs)

    def get_absolute_url(self):
        return reverse('books:chapter', args=[self.book.slug, self.book.pk, self.slug, self.pk])

    def get_admin_url(self):
        return reverse('admin:{}_{}_change'.format(self._meta.app_label, self._meta.model_name), args=[self.pk])

    def insert_page_separator(self):
        # разбиваем с умом текст на страницы, склеивая обратно устанавливаем разделитель страничек
        pattern = r'(?<=</p>)\n\n'
        pages = []
        buffer = ''
        # преобразовываем переводы строк к единому формату и удаляем старые разделители страниц
        text = re.sub(r'\r\n|\r', '\n', self.text).replace(self.page_separator, '')
        paragraphs = re.split(pattern, text)
        for p in paragraphs:
            buffer += p
            if len(buffer) >= settings.BOOKS_CHAPTER_CHARS_PER_PAGE:
                pages.append(buffer)
                buffer = ''
        if buffer:
            if not pages or len(buffer) >= settings.BOOKS_CHAPTER_MIN_PER_PAGE:
                pages.append(buffer)
            else:
                pages[-1] += buffer
        self.text = self.page_separator.join(pages)

    def update_positions(self):
        # проверяем порядок глав и при несоответствии поправляем их позиции
        position = 1
        for chapter in Chapter.objects.filter(book_id=self.book_id, published__isnull=False).exclude(pk=self.pk):
            if self.position == position:
                position += 1
            if chapter.position != position:
                # используем метод update, чтоб избежать вызов метода save
                Chapter.objects.filter(pk=chapter.pk).update(position=position)
            position += 1

    def pages(self):
        return self.text.split(self.page_separator)

    def prev(self):
        if self.published:
            return Chapter.public.filter(book_id=self.book_id, position__lt=self.position).select_related().last()

    def next(self):
        if self.published:
            return Chapter.public.filter(book_id=self.book_id, position__gt=self.position).select_related().first()

Модель Book состоит из названия, слогана, аннотации и обложки книги.

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

Значение completed указывает на статус книги, по умолчанию null, что означает: книга еще не завершена или содержит дату окончания соответственно.

В поле published аналогичная ситуация: если дата присутствует значит книга опубликована и не является черновиком.

Допускается запланированная публикация книг.

PublicBooksManager следит затем чтоб книги не попали в результирующий набор слишком рано и попутно отсеивает черновики.

Поля slug и summary мы уже описывали в предыдущих приложениях, как и утилиты для их создания в методе save.

Сортировка модели: последние добавления сверху.

Метод chapters создан для удобства, вытягивает доступные главы книги одним запросом.

Модель Chapter ссылается на модель книги и содержит описательные характеристики и содержимое самой главы.

Поля position и n_views принимают только положительные значения, позицию можно задать вручную, а количество просмотров введено только для внутренней статистики.

Роль поля published аналогично, как и в модели книг, но PublicChaptersManager не только отсекает черновики или не вышедшие главы, но и книги которые на данный момент не являются публичными.

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

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

Умная и автоматическая разбивка текста главы на страницы

В методе save после создания slug и summary мы вызываем метод insert_page_separator, в котором сначала приводим переводы строк к единому формату. 

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

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

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

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

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

Или представим другое: автор занимается написанием технической литературы и для приведения листингов программ использует теги pre и code.

Каково же будет его удивление когда на одной странице окажется начало кода, а на другой его продолжение...

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

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

Далее по окончанию цикла если что-то осталось в буфере мы проверяем: если нет ни одной сформированной страницы или содержимое буфера больше чем минимальное количество символов на страницу — выделяем начинку в новую страничку.

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

Здесь стоит отметить, что существует две характеристики: минимальное и положенное количество символов на страницу.

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

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

В таких случаях алгоритм будет проверять является ли последняя страница самодостаточной и в нашем случае это около 30% от положенного количество символов на страницу.

Затем мы сохраняем в поле модели text наши странички предварительно соединяю их через специальный страничный разделитель.

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

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

Сортировка и автоматический расчет позиций глав

Возвращаемся в метод save и далее у нас идет проверка: если статус главы — черновик, то она не может занимать позицию в потоке глав.

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

Затем нам необходимо получить номер последней позиции и указать ее для текущей опубликованный главы или скорректировать ее номер если пользователь вдруг ошибся при вводе номера позиции.

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

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

Иначе бы все запланированные главы получали бы один и тот же номер позиции.

Далее нам необходимо исключить текущую главу из выборки.

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

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

Например, если глав нет, мы получаем номер один.

После этого мы вызываем метод update_positions.

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

Для начала мы устанавливаем локальный счетчик позиции со значением: единица.

Затем в цикле мы перебираем все главы которые могут участвовать в выдаче.

Проверяем позицию текущего объекта главы со значением счетчика цикла.

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

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

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

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

И в конце цикла мы увеличиваем счетчик на один.

Рассмотрим пример: у нас есть пять опубликованных глав.

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

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

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

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

Для того чтобы далее обновить позицию главы из базы данных присвоив ей значение счетчика: три.

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

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

После того как мы обновили позиции переходим к сохранению текущего объекта главы вызывая стандартный метод модели save.

Далее нам необходимо переопределить метод delete.

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

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

И затем передать управление стандартному методу delete.

Ссылки на предыдущую и следующую главы

Методы prev и next указывают на предыдущий и следующий объекты глав.

Если текущая глав имеет дату публикацию: делаем запрос через менеджер опубликованных глав.

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

В запросе мы используем select_related поскольку ссылки будут содержать название книги.

А last и first — это просто удобная замена к получению последнего и первого объекта соответственно, поскольку по умолчанию сортировка модели: по порядку.

Настройки

Для внесения корректив открываем файл settings.py и добавляем приложение books:

INSTALLED_APPS = [
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
    'django.contrib.sitemaps',
    'ckeditor',
    'ckeditor_uploader',
    'core.apps.CoreConfig',
    'accounts.apps.AccountsConfig',
    'questions.apps.QuestionsConfig',
    'articles.apps.ArticlesConfig',
    'books.apps.BooksConfig',
]

Далее добавляем настройки для разбивки текста глав:

BOOKS_CHAPTER_CHARS_PER_PAGE = 5600  # максимум символов текста главы на страницу
BOOKS_CHAPTER_MIN_PER_PAGE = 1500  # минимум символов текста главы на страницу

Затем нам необходимо переименовать SITE_SUBTITLE на ARTICLES_SUBTITLE и добавить подзаголовок для приложения книг:

ARTICLES_SUBTITLE = 'Ликбез по охране труда'
BOOKS_SUBTITLE = 'Официальный сайт писателя'

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

После открываем в директории core файл с названием context_processors.py и удаляем SITE_SUBTITLE:

from django.conf import settings


def site(request):
    return {
        'SITE_AUTHOR': settings.SITE_AUTHOR,
        'SITE_TITLE': settings.SITE_TITLE,
    }

Так же необходимо отредактировать ленту обновлений (feeds.py) в приложение articles заменив SITE_SUBTITLE на ARTICLES_SUBTITLE.

Администрирование

Переходим к файлу admin.py приложения и приводим к следующему виду:

from django.contrib import admin
from django.db import models

from ckeditor_uploader.widgets import CKEditorUploadingWidget

from .models import Book, Chapter


@admin.register(Book)
class BookAdmin(admin.ModelAdmin):
    list_display = ['title', 'slug', 'completed', 'published']
    list_filter = ['completed', 'published']
    search_fields = ['title', 'slug']
    formfield_overrides = {models.TextField: {'widget': CKEditorUploadingWidget}}


@admin.register(Chapter)
class ChapterAdmin(admin.ModelAdmin):
    list_display = ['title', 'slug', 'n_views', 'position', 'published']
    list_filter = ['book__title', 'published']
    search_fields = ['title', 'slug']
    formfield_overrides = {models.TextField: {'widget': CKEditorUploadingWidget}}

Здесь, как и в предыдущих приложениях мы используем визуальный редактор CKEditor.

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

Затем указываем читабельное название приложения редактируя файл apps.py:

from django.apps import AppConfig


class BooksConfig(AppConfig):
    name = 'books'
    verbose_name = 'Книги'

Миграция

Переходим в командную строку и выполняем команды для создания и применения миграции приложения:

python manage.py makemigrations
python manage.py migrate