🐍😺🐙 Как сделать блог разработчика на GitHub Pages с помощью Django

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

Обзор проекта

Это генератор статических сайтов (SSG) на Django, который использует базу данных (а не Markdown файлы) для интеграции контента в HTML/CSS/JS страницы. Результат работы генератора – полностью статический блог, который с виду работает как динамический: смена темы оформления выполняются с помощью JavaScript, а обработка форм для отправки сообщения и подписки на рассылку реализована с использованием стороннего сервиса Formspree. Такой блог можно разместить на абсолютно любом статическом хостинге, включая GitHub Pages.

Демо-версию блога можно посмотреть здесь, а код доступен в репозитории django-ssg.

Темная версия (боковая панель в live-версии зафиксирована)
Светлая версия (боковая панель в live-версии зафиксирована)
Формы подписки и сообщений обрабатываются Formspree
Форма успешно обработана
Блог поддерживает 10 цветовых схем в темной и светлой версиях
Реализованы фильтрация постов по тегам и вывод предыдущей/следующей записи
Есть подсветка синтаксиса кода

Особенности GitHub Pages

GitHub Pages – статический хостинг: использовать бэкенд-логику и базы данных там нельзя. В качестве бэкенда на этой платформе по умолчанию используется SSG (генератор статических сайтов) Jekyll. Он поддерживает ограниченное количество тем, а поскольку набор допустимых плагинов тоже ограничен, кастомизация и расширение функциональности Jekyll-сайтов представляют собой серьезную задачу. Существует только одно решение этой проблемы: загружать на хостинг уже готовые HTML/CSS/JS страницы.

Генерировать такие страницы можно с помощью абсолютно любого SSG – их число растет в геометрической прогрессии, есть из чего выбрать. Однако зачастую сделать собственный генератор бывает гораздо проще, чем разбираться в настройках готового SSG. Разработка генератора – несложный и увлекательный процесс: мы уже публиковали туториал о создании SSG на основе Flask и плагина Frozen-Flask. Как и абсолютное большинство других генераторов, он создает статические страницы, используя HTML/CSS/JS шаблоны и контент из файлов Markdown.

Если же вы привыкли к Django, и вместо возни с Markdown предпочитаете экспортировать записи из базы данных, для создания SSG можно воспользоваться расширением django-distill.

Как работает django-distill

Расширение django-distill выгодно отличается от других SSG-плагинов для Django (например, django-bakery) предельной простотой использования и высокой скоростью обработки файлов. Изменения в динамическую версию приложения вносятся только в одном месте – urls.py, обычные маршруты path заменяются «дистиллированными». Например, достаточно заменить стандартный маршрут index на этот, и расширение сгенерирует статический файл index.html:

distill_path('',
                IndexView.as_view(),
                name='index',
                distill_file='index.html'),)

А для экспорта всего приложения в статическую версию нужно всего лишь выполнить команду:

python manage.py distill-local mysite --collectstatic

Ограничения django-distill

Плагин не обрабатывает URL, в которых содержатся запросы и параметры:

http://mysite.com/?page=3
http://mysite.com/blog?article_id=123&title=abc

По этой причине django-distill не будет обрабатывать динамическую пагинацию: он экспортирует лишь первую страницу index.html, но не последующие. Хотя разработчики плагина не предлагают решение этой проблемы, оно существует – рассмотрим его ниже.

Как сделать статический сайт из приложения Django

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

python -m venv progblog\venv

Перейдите в директорию проекта и активируйте окружение:

venv\scripts\activate

Установите Django, а затем django-distill:

pip install django
pip install django-distill

Создайте новый проект:

django-admin startproject config .

И приложение blog:

python manage.py startapp blog

Настройки проекта

В файле settings.py нужно сделать следующие настройки:

  • Добавить django-distill и blog в раздел INSTALLED_APPS:
INSTALLED_APPS = [
    'django_distill',
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
    'blog.apps.BlogConfig',
]
  • Изменить язык на русский, а время – на нужный часовой пояс:
LANGUAGE_CODE = 'ru'

TIME_ZONE = 'Europe/Moscow'
  • Прописать пути к статическим и медийным файлам:
STATIC_URL = '/static/'
STATIC_ROOT = BASE_DIR / "staticfiles"
STATICFILES_DIRS = [BASE_DIR / "static"]
MEDIA_URL = '/media/'
MEDIA_ROOT = BASE_DIR / "media"

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

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

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

urlpatterns += static(settings.MEDIA_URL, 
    document_root=settings.MEDIA_ROOT)
urlpatterns += static(settings.STATIC_URL, 
    document_root=settings.STATIC_ROOT)

Разработка блога

Прежде всего нужно создать базу данных с помощью команды:

python manage.py migrate

На этом же этапе стоит создать админский аккаунт:

python manage.py createsuperuser

После этого можно переходить к созданию схемы базы. Создайте модели Post и Tag в файле models.py:

from django.db import models

class Tag(models.Model):

    name = models.SlugField(max_length=25, db_index=True, 
        unique=True, help_text='Тег')

    def __str__(self):
        return self.name


class Post(models.Model):

    created = models.DateTimeField(auto_now_add=True, 
        help_text='Дата создания поста')
    read = models.CharField(max_length=2, default='1', 
        null=True, blank=True, help_text='Время на чтение')
    title = models.CharField(max_length=200, help_text='Заголовок поста')
    slug = models.SlugField(help_text='Слаг поста', db_index=True,
        unique=True, null=True, blank=True)
    content = models.TextField(help_text='Содержание поста')
    image = models.FileField(upload_to='images/', null=True, blank=True)
    tags = models.ManyToManyField(Tag, help_text='Теги')

    def __str__(self):
        return self.title

Подготовьте и примените миграции:

python manage.py makemigrations
python manage.py migrate

Готовую заполненную базу данных можно взять в репозитории проекта.

Представления

Представления находятся в файле views.py:

  • IndexView(ListView) выводит записи на главную страницу и обеспечивает пагинацию (по шесть постов для каждой страницы):
class IndexView(ListView):
    template_name = 'index.html'
    model = Post
    allow_empty = False
    ordering = '-created'
    context_object_name = 'posts'
    paginate_by = 6
  • PostView(DetailView) показывает отдельные записи и предоставляет ссылки на предыдущий и последующий посты (если они существуют):
class PostView(DetailView):
    template_name = 'post.html'
    model = Post

    def get_context_data(self, **kwargs):
        context = super().get_context_data(**kwargs)
        post = self.object
        prev_post = Post.objects.filter(created__lt=post.created).order_by('-created').first()
        next_post = Post.objects.filter(created__gt=post.created).order_by('created').first()
        context['prev_post'] = prev_post
        context['next_post'] = next_post
        return context
  • TagView(ListView) обеспечивает выборку постов, отмеченных определенным тегом. Эти записи также разбиваются пагинатором по 6 постов на страницу:
class TagView(ListView):
    template_name = 'tag.html'
    context_object_name = 'posts'
    paginate_by = 6

    def get_queryset(self):
        return Post.objects.filter(tags__name=self.kwargs['tag']).prefetch_related('tags')

    def get_context_data(self, **kwargs):
        context = super().get_context_data(**kwargs)
        context['tag'] = get_object_or_404(Tag, name=self.kwargs['tag'])
        return context
  • Страница с контактной формой не использует серверную логику Django, поэтому шаблонизатор собирает ее из двух шаблонов и выводит без каких-либо манипуляций:
class ContactView(TemplateView):
    template_name = 'contact.html'

Шаблоны

Проект использует несколько шаблонов. Основные шаблоны:

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

Все нужны скрипты находятся в статической директории и подключаются в шаблоне base.html.

Генерация статических страниц

В отличие от стандартного динамического приложения Django, вся логика генератора сосредоточена не в views.py, а в urls.py. Использование distill_path вместо обычного path дает плагину django-distill понять, что все страницы, соответствующие этому маршруту, нужно экспортировать в виде HTML/CSS/JS файлов. Если distill_path нужно сгенерировать множество страниц по одному и тому же шаблону – например, страницы всех записей в хронологическом порядке или по определенному тегу, – то для получения всех слагов и всех тегов он использует соответствующие функции:

def get_posts():
    for post in Post.objects.all():
        yield {'slug': post.slug}


def get_tags():
    for tag in Tag.objects.all():
        yield {'tag': tag.name}

...

    distill_path('posts/<slug:slug>.html',
                 PostView.as_view(),
                 name='blog-post',
                 distill_func=get_posts),

    distill_path('tags/<slug:tag>.html',
                 TagView.as_view(),
                 name='blog-tag',
                 distill_func=get_tags),

И напротив, генератор создает index.html и contact.html в единственных экземплярах, поэтому получение дополнительных данных из функций им не требуется:

    distill_path('',
                 IndexView.as_view(),
                 name='blog-index',
                 distill_file='index.html'),
...
    distill_path('contact.html',
                 ContactView.as_view(),
                 name='contact',
                 distill_file='contact.html'),

Обработка пагинации

Упомянутая выше проблема с обработкой стандартной динамической пагинации решается с помощью этой функции:

def get_paginated_index():
    posts = Post.objects.all()
    paginator = Paginator(posts, 6)
    for page_num in range(1, paginator.num_pages + 1):
        yield {'page': page_num}

И этого маршрута:

distill_path('pages/page<int:page>.html', 
                 IndexView.as_view(), 
                 name='blog-index-paginated', 
                 distill_func=get_paginated_index),

Кроме того, статическая пагинация требует специфической реализации в шаблоне _pagination.html. Этот код обеспечивает вывод страницы index.html при нажатии на первую кнопку пагинации. Все остальные страницы будут выводиться как page2.html, page3.html и так далее:

   {% if is_paginated %}
   <div class="pagination">
      {% if page_obj.has_previous %}
      <a class="btn btn-outline-primary mb-4" href="{% if page_obj.previous_page_number == 1 %}/{% else %}/pages/page{{ page_obj.previous_page_number }}.html{% endif %}">Предыдущая</a>
      {% endif %}
      {% for num in page_obj.paginator.page_range %}
      {% if page_obj.number == num %}
      <a class="btn btn-primary mb-4">{{ num }}</a>
      {% else %}
      <a class="btn btn-outline-primary mb-4" href="{% if num == 1 %}/{% else %}/pages/page{{ num }}.html{% endif %}">{{ num }}</a>
      {% endif %}
      {% endfor %}
      {% if page_obj.has_next %}
      <a class="btn btn-outline-primary mb-4" href="/pages/page{{ page_obj.next_page_number }}.html">Следующая</a>
      {% endif %}
   </div>
   {% endif %}

Обработка форм

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

<form id="contact-form" action="https://formspree.io/f/xenyqrle" method="POST">

Другие сервисы, похожие на Formspree: Formspark, Formcarry, Getform, FormKeep, FormBackend.

🐍 Библиотека питониста
Больше полезных материалов вы найдете на нашем телеграм-канале «Библиотека питониста»
🐍🎓 Библиотека Python для собеса
Подтянуть свои знания по Python вы можете на нашем телеграм-канале «Библиотека Python для собеса»
🐍🧩 Библиотека задач по Python
Интересные задачи по Python для практики можно найти на нашем телеграм-канале «Библиотека задач по Python»

Подсветка синтаксиса

Для подсветки кода блог использует библиотеку highlight.js, которая поддерживает 192+ языков и 498 тем. В этом проекте используется тема Dracula, выбрать другую тему можно в разделе Examples библиотеки highlight.js.

Экспорт статических страниц из приложения Django

Когда ваш сайт полностью готов к загрузке на хостинг, нужно выполнить упомянутую выше команду:

python manage.py distill-local mysite --collectstatic

После чего django-distill экспортирует HTML/CSS/JS страницы в директорию mysite. Перед загрузкой на хостинг стоит убедиться, что статическая версия сайта функционирует корректно. Для этого нужно вызвать cmd в директории сайта и выполнить команду для запуска локального сервера:

python -m http.server

После этого статическую версию сайта можно будет открыть в браузере по адресу http://localhost:8000/.

Нюансы хостинга GitHub Pages

GitHub Pages разрешает создать 1 сайт с адресом username.github.io и сколько угодно сайтов типа username.github.io/repo-name. Чтобы GitHub Pages начал раздавать статические файлы из репозитория, нужно этот репозиторий настроить следующим образом:

  • Перейдите в раздел Settings в репозитории, в котором находятся файлы сайта, и выберите Pages.
  • На странице Pages выберите нужную ветку – скорее всего это будет main, – и нажмите Save:

Все! Через несколько секунд сайт начнет работать.

Если у вас уже есть один сайт на username.github.io
Необходимо немного подправить все ссылки нового сайта, начинающиеся с названия директорий (то есть static, media и т.д.). Иначе хостинг будет искать эти ресурсы в репозитории username. Быстрее всего это можно сделать с помощью такого скрипта. Другой вариант – прописать repo-name прямо в distill_path.

Если что-то не получилось, пишите в комментариях – поможем!

***

🐍 Python: от новичка до Junior за 32 урока

Ключевые моменты курса по Python, который действительно заслуживает внимания:

  • 4 практических проекта для портфолио: от парсера до Telegram-бота
  • Преподают специалисты из Мегафона с опытом в ML и NLP
  • 90+ часов теории и практики с доступом навсегда
  • Обучение можно совмещать с работой

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

ЛУЧШИЕ СТАТЬИ ПО ТЕМЕ

admin
11 декабря 2018

ООП на Python: концепции, принципы и примеры реализации

Программирование на Python допускает различные методологии, но в его основе...
admin
13 февраля 2017

Программирование на Python: от новичка до профессионала

Пошаговая инструкция для всех, кто хочет изучить программирование на Python...