Генерация страниц статичного сайта с помощью Flask-приложения

Это ключевой урок третьего раздела курса.

По большому счету речь пойдет о записи строки, возвращаемой функцией render_template(), в файл, а не только передачи ее веб-серверу из функции-представления. Такие готовые файлы HTML будут потом загружены на удаленный VPS. Само приложение на базе фреймворка Flask используется лишь для создания и обслуживания сайта.

Протестируем идею на примере главной страницы. Пусть при ее запросе вместе с возвратом результата render_template() из функции-представления index() также создается файл index.html:

@app.route('/')
def index():
    s = render_template('base.html')
    with open('static/index.html', 'w') as f: 
        f.write(s)
    return s

Теперь, загрузив/обновив главную страницу в браузере, получим новый файл в каталоге static. Его можно открыть в браузере, но ни стили, ни скрипты работать не будут, так как в коде HTML была использована абсолютная адресация. Запустим отдельный простой веб-сервер из каталога static:

$ cd static/
$ python3 -m http.server 

По адресу http://0.0.0.0:8000/ откроется страница index.html.

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

Во-первых, в шаблонах нашего flask-приложения содержатся ссылки на страницу редактирования статьи и создание новой (а также блок для вывода flask-сообщений). Этого кода HTML в статичных страницах быть не должно. Чтобы удалить его оттуда, будем передавать дополнительный аргумент при вызове render_template(), а сам html-код поместим в условный оператор jinja.

В файле page.html:

{% if not del_buttons %}
  <div style="background-color:rgba(57,102,95,0.14);height:30px;padding:5px 10px;">
    <a href="{{url_for('edit_page', file_name=address[1:])}}"
       style="float:left;">Править эту статью</a>
    <a href="{{url_for('create')}}" style="float:right;">Создать новую</a>
  </div>
{% endif %}

В файле base.html:

{% if not del_buttons %}
  <style>  
    .error { color: red; text-align: center; }
    .success { color: green; text-align: center; }
  </style> 
  {% for category, message in get_flashed_messages(with_categories=true) %}
      <p class="{{ category }}">{{ message }}</p>
  {% endfor %}
{% endif %}

Внесем изменения в функцию index, выполнив два вызова render_template(). Один ‒ для записи в файл, другой ‒ для передачи веб-серверу. (На главной странице flask-сообщения выводятся при удалении статьи, когда происходит перенаправление сюда.)

@app.route('/')
def index():
    with open('static/index.html', 'w') as f:
        f.write(render_template('base.html', del_buttons=True))
    return render_template('base.html')

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

Вторая проблема ‒ как в Python создавать деревья каталогов. Так, если есть статья с путем /botany/plants, то в нашем каталоге static должен быть каталог botany, а уже в нем будет создан файл plants.html.

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

Каждая статья в базе данных имеет поле path, в которой хранится ее путь в виде строки. Если мы создаем для статьи статичный файл, то имя файла ‒ это подстрока пути, находящаяся после последнего слэша. Чтобы отделить дерево каталогов от имени файла, воспользуемся строковым методом rpartition. Пояснительный пример:

>>> path = 'zoology/protozoa/amoeba'
>>> folders_and_file = path.rpartition('/')
>>> folders_and_file
('zoology/protozoa', '/', 'amoeba')
>>> folders = folders_and_file[0]
>>> folders
'zoology/protozoa'

Далее передадим дерево каталогов в конструктор Path:

>>> from pathlib import Path
>>> path_object = Path(folders)

Вызовем метод mkdir:

>>> path_object.mkdir(parents=True, exist_ok=True)

Параметр parents позволяет создавать родительские каталоги, если их нет; exist_ok ‒ не выбрасывать ошибку, если каталоги уже были созданы ранее.

Теперь мы готовы написать функцию make_site, генерирующую сразу все страницы сайта:

def make_site():
    with open(f'static/index.html', 'w') as f:
        f.write(render_template('base.html', del_buttons=True))
    c = sqlite3.connect(db_name)
    c.row_factory = sqlite3.Row
    articles = c.execute('SELECT * FROM articles')
    for i in articles:
        s = render_template('page.html',
                            address='/' + i['path'],
                            title=i['h1'],
                            desc=i['short_desc'],
                            body=Markup(i['body']),
                            del_buttons=True)
        Path(f"static/{i['path'].rpartition('/')[0]}"
             ).mkdir(parents=True, exist_ok=True)
        f = open(f"static/{i['path']}.html", 'w')
        f.write(s)
        f.close()
    c.close()

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

Вызовем make_site() из функции index (вызов должен быть до оператора return) и обновим в браузере главную страницу. В каталоге static должны появиться каталоги и страницы. После этого вызов следует закомментировать до следующего случая, когда понадобится снова сгенерировать статичный сайт целиком.

Если сейчас запустить простейший веб-сервер из каталога static (см. выше), можно, переходя по ссылкам, открыть любую страницу сайта, если в конце каждого адреса дописывать .html. Чтобы "скрывать" расширения, программный веб-сервер надо настроить, то есть прописать соответствующие инструкции. Как это можно сделать, показано в следующей статье. О том, как избавиться от расширений на веб-сервере Apache, упомянем, когда речь пойдет о переносе сайта на VPS.

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

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

Начнем с простого. Определим все места в программе, куда следует вставить вызов make_site(), то есть выполнять генерацию всех страниц:

Дополнительно, в функции delete следует предусмотреть удаление файла статьи из каталога static:

import os
...
def delete(address):
    ... 
    menu_update()
    try:
        os.remove(f'static/{address}.html')
    except FileNotFoundError:
        pass
    make_site()

Конструкцию try-except также надо вставить туда, где у страницы меняется адрес. Потому что появится документ с новым именем, а старый должен быть удален.

try:
    os.remove(f'static/{file_name}.html')
except FileNotFoundError:
    pass
update('path', 'path')
flash('Адрес страницы изменен!', 'success')

Что касается генерации всего одной статичной страницы, то данное действие должно происходить в местах, когда меняется тело статьи или ее краткое описание.

if request.form['article'] != p['body']:
    updated = update('body', 'article')
    # здесь должна быть запись в файл одной страницы

if request.form['description'] != p['short_desc']:
    updated = update('short_desc', 'description')
    # здесь должна быть запись в файл одной страницы

Однако если правится и то, и то, мы два раза перезапишем один и тот же файл. Чтобы не допустить лишних действий, определим переменную changed_one_page, в соответствующих местах присвоим ей True, и уже перед выходом из функции перезапишем файл:

... 
if request.method == 'POST':
    updated = False
    success = True
    changed_one_page = False
... 
    if request.form['article'] != p['body']:
        updated = update('body', 'article')
        changed_one_page = True

    if request.form['description'] != p['short_desc']:
        updated = update('short_desc', 'description')
        changed_one_page = True
    ... 
    if success:
        if changed_one_page:
            make_one_page(file_name)
        return redirect(url_for('page', file_name=file_name))

Функция make_one_page:

def make_one_page(path):
    c = sqlite3.connect(db_name)
    c.row_factory = sqlite3.Row
    p = c.execute('SELECT path, h1, short_desc, body \
                      FROM articles WHERE path=?',
                  (path,)).fetchone()
    c.close()
    s = render_template('page.html',
                        address='/' + p['path'],
                        title=p['h1'],
                        desc=p['short_desc'],
                        body=Markup(p['body']),
                        del_buttons=True)
    Path(f"static/{path.rpartition('/')[0]}").mkdir(
        parents=True, exist_ok=True)
    f = open(f'static/{path}.html', 'w')
    f.write(s)
    f.close()

Мы можем продолжить оптимизацию (если сложно, можно этого не делать). Все должно работать и так.

Теперь представьте, что по ходу действия (например, в результате изменения текста ссылки в меню) были перезаписаны все страницы сайта. Также мы внесли изменения в тело статьи, чем изменили значение changed_one_page на True. Но нет необходимости теперь перезаписывать одну страницу. Значит, надо проверять, выполнялась ли до этого функция make_site() или нет.

Кроме того, и самое страшное, в ряде случаев make_site() и menu_update() могут выполняться по несколько раз. Например, на странице редактирования статьи вы изменили ее заголовок и положение в разделе меню. Сначала эта пара функций будет вызвана при обработке изменения заголовка, потом ‒ позиции.

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

Обратим внимание на моменты выхода из функции ‒ операторы return. Выход с результатом рендера шаблона create.html нас не волнует, так как это значит, что при заполнении формы была допущена некорректность, и страница снова будет открыта на редактирование.

Но при редиректах на page запись статичных страниц и меню может происходить. Почему бы не выполнять эти действия в функции page, а не edit_page. Ведь Flask позволяет передавать в функции-представления дополнительные аргументы.

Удалим код:

if changed_one_page:
    make_one_page(file_name)

Дополнительно определим переменную changed_all_pages:

changed_one_page = False
changed_all_pages = False

В функции edit_page вместо вызовов menu_update() и make_site() будем менять значение переменной:

changed_all_pages = True

В редиректы на page добавим дополнительные аргументы:

        return redirect(url_for('page', file_name=request.form['path'],
                                write_all=changed_all_pages))

if success:
    return redirect(url_for('page', file_name=file_name,
                            write_all=changed_all_pages,
                            write_one=changed_one_page
                            ))

В теле page получим значение этих аргументов и в зависимости от них вызовем функции menu_update(), make_site() или make_one_page():

if p:
    write_all = request.args.get('write_all')  # возвращает строку
    write_one = request.args.get('write_one')
    if write_all == 'True':
        menu_update()
        make_site()
    elif write_one == 'True':
        make_one_page(p)

    return render_template('page.html',
                           address='/' + p['path'],
                           title=p['h1'],
                           desc=p['short_desc'],
                           body=Markup(p['body']))

В функции make_one_page незачем извлекать данные из базы данных. Они такие же как уже были получены в page. Поэтому мы можем передать запись. Преобразуем вызов из page:

make_one_page(p)

Функция make_one_page:

def make_one_page(p):
    s = render_template('page.html',
                        address='/' + p['path'],
                        title=p['h1'],
                        desc=p['short_desc'],
                        body=Markup(p['body']),
                        del_buttons=True)
    Path(f"static/{p['path'].rpartition('/')[0]}").mkdir(
        parents=True, exist_ok=True)
    f = open(f'static/{p["path"]}.html', 'w')
    f.write(s)
    f.close()

Flask для начинающих




Все разделы сайта