Простой блог на Flask

Создание блога кажется квинтэссенцией опыта при изучении нового веб-фреймворк. Я надеюсь, что этот пост даст вам инструменты для создания блога. В этой статье мы рассмотрим основы, чтобы получить функциональный сайт, но оставим много места для персонализации и улучшения. Фактический исходник блога поместится в 200 строчек Python кода.

Эта статья предназначено для начинающих разработчиков Python, среднего уровня, или опытных разработчиков, которые решили изучить Flask.

Спецификация

Особенности создаваемого блога:

  • Записи, отформатированные с использованием markdown
  • Записи поддерживают подсветку синтаксиса
  • Автоматическое видео/мультимедиа вставка с помощью OEmbed.
  • Очень красивый полнотекстовый поиск благодаря расширению SQLite FST.
  • Пагинация
  • Публикация записей

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

Главная страница

p1425775025.68_800x800
image-4249

 

Страница записей

p1425775019.9_800x800
image-4250

 

Начинаем

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

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

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

  • Flask, сам веб-фреймворк.
  • Peewee,  для хранения записей в базе данных и выполнения запросов.
  • pygments, подсветка синтаксиса с поддержкой огромного количества разных языков.
  • Markdown, форматирование для наших записей.
  • micawber, для преобразования адресов в обьекты. Например, если вы хотите встроить видео с YouTube, просто поместите URL на видео в свой пост и видео-плеер будет автоматически появляться на этом месте.
  • BeautifulSoup, требуется Микобером для разбора HTML.

 

$ virtualenv blog
New python executable in blog/bin/python2
Also creating executable in blog/bin/python
Installing setuptools, pip...done.
$ cd blog/
$ source bin/activate
(blog)$ pip install flask peewee pygments markdown micawber BeautifulSoup
...
Successfully installed flask peewee pygments markdown micawber BeautifulSoup Werkzeug Jinja2 itsdangerous markupsafe
Cleaning up...

Наш приложение будет находится в файле app.py. Мы также создадим несколько папок для статических файлов(стилей, JavaScript файлов) и папку для шаблонов.

(blog)$ mkdir app
(blog)$ cd app
(blog)$ touch app.py
(blog)$ mkdir {static,templates}

Настройка приложения Flask

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

# app.py
import datetime
import functools
import os
import re
import urllib
from flask import (Flask, abort, flash, Markup, redirect, render_template,
                   request, Response, session, url_for)
from markdown import markdown
from markdown.extensions.codehilite import CodeHiliteExtension
from markdown.extensions.extra import ExtraExtension
from micawber import bootstrap_basic, parse_html
from micawber.cache import Cache as OEmbedCache
from peewee import *
from playhouse.flask_utils import FlaskDB, get_object_or_404, object_list
from playhouse.sqlite_ext import *
ADMIN_PASSWORD = 'secret'
APP_DIR = os.path.dirname(os.path.realpath(__file__))
DATABASE = 'sqliteext:///%s' % os.path.join(APP_DIR, 'blog.db')
DEBUG = False
SECRET_KEY = 'shhh, secret!'  # Used by Flask to encrypt session cookie.
SITE_WIDTH = 800
app = Flask(__name__)
app.config.from_object(__name__)
flask_db = FlaskDB(app)
database = flask_db.database
oembed_providers = bootstrap_basic(OEmbedCache())

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

Определение моделей баз данных

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

У модели записей будут следующие столбцы:

  • title
  • slug: для понятного url.
  • content: содержание записи.
  • published:  флаг, указывающий публикуется ли запись.
  • timestamp: раз создается запись.
  • id: PeeWee автоматически создаст первичный ключ автоинкрементный для нас, так что мы не должны определять его явно.

Индекс поиска будут храниться с использованием модели класса FTSEntry:

  • entry_id: первичный ключ индексированной записи.
  • content: поиск контента для данной записи.

Добавим следующий код после конфигурации в наш app.py:

class Entry(flask_db.Model):
    title = CharField()
    slug = CharField(unique=True)
    content = TextField()
    published = BooleanField(index=True)
    timestamp = DateTimeField(default=datetime.datetime.now, index=True)
    def save(self, *args, **kwargs):
        if not self.slug:
            self.slug = re.sub('[^\w]+', '-', self.title.lower())
        ret = super(Entry, self).save(*args, **kwargs)
        # Store search content.
        self.update_search_index()
        return ret
    def update_search_index(self):
        try:
            fts_entry = FTSEntry.get(FTSEntry.entry_id == self.id)
        except FTSEntry.DoesNotExist:
            fts_entry = FTSEntry(entry_id=self.id)
            force_insert = True
        else:
            force_insert = False
        fts_entry.content = '\n'.join((self.title, self.content))
        fts_entry.save(force_insert=force_insert)
class FTSEntry(FTSModel):
    entry_id = IntegerField(Entry)
    content = TextField()
    class Meta:
        database = database

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

Также обратите внимание, что мы создали несколько полей с индексом = True. Это говорит Peewee создать вторичный индекс по столбцам.

 

Код инициализации

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

@app.template_filter('clean_querystring')
def clean_querystring(request_args, *keys_to_remove, **new_values):
    querystring = dict((key, value) for key, value in request_args.items())
    for key in keys_to_remove:
        querystring.pop(key, None)
    querystring.update(new_values)
    return urllib.urlencode(querystring)
@app.errorhandler(404)
def not_found(exc):
    return Response('
<h3>Not found</h3>
'), 404
def main():
    database.create_tables([Entry, FTSEntry], safe=True)
    app.run(debug=True)
if __name__ == '__main__':
    main()

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

$ cd blog  # switch to the blog virtualenv directory.
$ source bin/activate  # activate the virtualenv
(blog)$ cd app  # switch to the app subdirectory
(blog)$ python app.py
 * Running on http://127.0.0.1:5000/ (Press CTRL+C to quit)
 * Restarting with reloader

Добавим функции входа и выхода

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

def login_required(fn):
    @functools.wraps(fn)
    def inner(*args, **kwargs):
        if session.get('logged_in'):
            return fn(*args, **kwargs)
        return redirect(url_for('login', next=request.path))
    return inner
@app.route('/login/', methods=['GET', 'POST'])
def login():
    next_url = request.args.get('next') or request.form.get('next')
    if request.method == 'POST' and request.form.get('password'):
        password = request.form.get('password')
        if password == app.config['ADMIN_PASSWORD']:
            session['logged_in'] = True
            session.permanent = True  # Use cookie to store session.
            flash('You are now logged in.', 'success')
            return redirect(next_url or url_for('index'))
        else:
            flash('Incorrect password.', 'danger')
    return render_template('login.html', next_url=next_url)
@app.route('/logout/', methods=['GET', 'POST'])
def logout():
    if request.method == 'POST':
        session.clear()
        return redirect(url_for('login'))
    return render_template('logout.html')

Обратите внимание, что вьюхи входа и выхода делают разные вещи в зависимости от того, был ли запрос GET или POST. При переходе на страницу /login/, вы увидите шаблон с полем ввода. Когда вы отправляете форму, вьюха будет проверять пароль, и перенаправить на главную или отобразить сообщение об ошибке.

Реализация views

Теперь, когда мы создали фундамент нашего сайта, мы можем начать работать над тем, что на самом деле будет использоваться для отображения и управления записей в блоге. Благодаря некоторым из помощников в модуле flask_utils Playhouse, код будет минимальным.

Главная, поиск, черновики

Давайте начнем с главной страницы. Это будет страница с записями, с сортировкой по времени, и будут отображаться последние 20 записей. Мы будем использовать object_list из flask_utils, который принимает запрос и возвращает запрошенную страницу объектов. Кроме того, индекс страницы позволит пользователям осуществлять поиск.

Добавим следующий код после кода аутентификации:

@app.route('/')
def index():
    search_query = request.args.get('q')
    if search_query:
        query = Entry.search(search_query)
    else:
        query = Entry.public().order_by(Entry.timestamp.desc())
    return object_list('index.html', query, search=search_query)

Если поисковый запрос присутствует, как указано в GET аргумента q, будем вызывать метод Entry.search(). Этот метод будет использовать SQLite полнотекстовый поисковый индекс для запроса соответствующих записей. Полнотекстовый поиск SQLite поддерживает логические запросы, цитирование, и многое другое.

Вы можете заметить, что мы также вызываем Entry.public(),  если нет поискового запроса. Этот метод вернет только опубликованные записи.

Для реализации этого, добавим следующие методы в наш класс:

@classmethod
def public(cls):
    return Entry.select().where(Entry.published == True)
@classmethod
def search(cls, query):
    words = [word.strip() for word in query.split() if word.strip()]
    if not words:
        # Return empty query.
        return Entry.select().where(Entry.id == 0)
    else:
        search = ' '.join(words)
    return (FTSEntry
            .select(
                FTSEntry,
                Entry,
                FTSEntry.rank().alias('score'))
            .join(Entry, on=(FTSEntry.entry_id == Entry.id).alias('entry'))
            .where(
                (Entry.published == True) &
                (FTSEntry.match(search)))
            .order_by(SQL('score').desc()))

Давайте кратко разберемся в методе search. Здесь мы запрашиваем таблицу FTSEntry, в которой хранится поисковые индексы наших записей. Полнотекстовый поиск SQLite внедряет оператор MATCH, который мы будем использовать, чтобы соответствовать индексированный контент от поискового запроса. Мы также присоединяемся к таблице записей таким образом, что мы вернем только опубликованные записи.

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

@classmethod
def drafts(cls):
    return Entry.select().where(Entry.published == False)
@app.route('/drafts/')
@login_required
def drafts():
    query = Entry.drafts().order_by(Entry.timestamp.desc())
    return object_list('index.html', query)

Детальная страница записей

Мы будем использовать дружественные URL. Вы, возможно, помните, что мы перегрузили метод Entry.save() для заполнения поля slug с URL. Добавим следующий код в наше приложение:

@app.route('/<slug>/')
def detail(slug):
    if session.get('logged_in'):
        query = Entry.select()
    else:
        query = Entry.public()
    entry = get_object_or_404(query, Entry.slug == slug)
    return render_template('detail.html', entry=entry)

Get_object_or_404 определен в модуле Playhouse flask_utils и, если объект, соответствующих запросу не найдено, возвращает ответ 404.

Отображение содержание записей

Для того, чтобы преобразовать тексты записей в формате HTML, мы добавим дополнительное свойство к нашему классу. Это свойство будет включить содержание записи в HTML и конвертировать медиа ссылки в встроенных объекты (т.е. URL YouTube становится видеоплеер).

Добавим следующее свойство модели:

@property
def html_content(self):
    hilite = CodeHiliteExtension(linenums=False, css_class='highlight')
    extras = ExtraExtension()
    markdown_content = markdown(self.content, extensions=[hilite, extras])
    oembed_content = parse_html(
        markdown_content,
        oembed_providers,
        urlize_all=True,
        maxwidth=app.config['SITE_WIDTH'])
    return Markup(oembed_content)

Объект Markup говорит Flask что мы доверяем содержание HTML, поэтому он не будет обрезать его при выводе.

Создание и редактирование записей

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

@app.route('/create/', methods=['GET', 'POST'])
@login_required
def create():
    if request.method == 'POST':
        if request.form.get('title') and request.form.get('content'):
            entry = Entry.create(
                title=request.form['title'],
                content=request.form['title'],
                published=request.form.get('published') or False)
            flash('Entry created successfully.', 'success')
            if entry.published:
                return redirect(url_for('detail', slug=entry.slug))
            else:
                return redirect(url_for('edit', slug=entry.slug))
        else:
            flash('Title and Content are required.', 'danger')
    return render_template('create.html')
@app.route('/<slug>/edit/', methods=['GET', 'POST'])
@login_required
def edit(slug):
    entry = get_object_or_404(Entry, Entry.slug == slug)
    if request.method == 'POST':
        if request.form.get('title') and request.form.get('content'):
            entry.title = request.form['title']
            entry.content = request.form['content']
            entry.published = request.form.get('published') or False
            entry.save()
            flash('Entry saved successfully.', 'success')
            if entry.published:
                return redirect(url_for('detail', slug=entry.slug))
            else:
                return redirect(url_for('edit', slug=entry.slug))
        else:
            flash('Title and Content are required.', 'danger')
    return render_template('edit.html', entry=entry)

Далее осталось добавить только шаблоны у статический файлы. Их вы можете скачать с GitHux, ссылка в начале статьи.