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

Особенно хочется поделиться про фильтры и шаблоны, довольно удачно получилось!

Bzr-репозиторий http://softwaremaniacs.org/code/cicero/
Работающий форум http://softwaremaniacs.org/forum/

Подключение фильтров

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

Получилось так. В приложении я сделал отдельную папку "filters" с файлом "__init__.py". Этого Питону достаточно, чтобы уметь импортировать эту папку как модуль (пакет, если точнее). В этой папке и будут хранится .py-файлы — подмодули отдельных фильтров. Все это вполне стандартный Питон.

А дальше в файле "__init__.py", который автоматически выполняется при любой попытке импортировать что-то из пакета "filters", я написал небольшой инициализирующий код, который:

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

Инициализатор выглядит довольно просто:

# будущий словарь фильтров
filters = {}

# получить имена py-файлов из директории фильтров
directory = os.path.dirname(os.path.abspath(__file__))
names = [name for name in os.listdir(directory) if name.endswith('.py') and name != '__init__.py']
module_names = [os.path.splitext(name)[0] for name in names]

# каждый модуль пытается импортироваться и записываться в filters
for module_name in module_names:
  module = __import__('cicero.filters.' + module_name, {}, {}, [''])
  try:
    filters[module.name()] = module.to_html
  except AttributeError:
    pass

Особенно проницательные читатели могут спросить, а зачем в модуле иметь отдельную функцию "name()", возвращающую название фильтра, если можно условиться брать название самого модуля? Это из-за того, что я предполагаю, что фильтры будут чаще не сами содержать код конвертации, а импортировать из системы соответствующую библиотеку и вызывать ее.

Так вот если из фильтра с названием "markdown" вызвать библиотеку "markdown", то ничего хорошего не выйдет:

cicero/filters/markdown.py:

import markdown # импортирует сам себя!

И хоть Питон 2.5 решил эту проблему, не везде он еще стоит. Поэтому фильтр приходится называть не так, как библиотеку, а как-нибудь криво ("mardkown_plugin"). А показывать это название пользователю некрасиво. Отсюда и нужда в функции "name()", которая вернет нормальное человекочитаемое название.

Использовать фильтр в общих чертах просто:

from cicero.filters import filters
html = filters['bbcode']() # filters[...] -- функция to_html из нужного модуля

Но поскольку фильтры могут удаляться, я предусмотрел еще откатное поведение: если фильтра в реестре нет, то текст обрабатывается двумя Django'вскими функциями — "escape" и "linebreaks". Первая экранирует HTML, вторая заменяет переводы строк на теги, чтобы сохранять хотя бы разбиение на строки и абзацы.

Фильтры

Сейчас в Cicero есть два фильтра: bbcode и markdown.

С первым возникла "интересная" проблема. Избалованный Питоном, я думал, что есть какой-то один хороший модуль для обработки bbcode, которым все пользуются. Но оказалось, что их много. Мне больше всего понравился bbcode в реализации Люка Планта (известного джангоиста, кстати). Понравилось прежде всего то, что он не просто прогоняет набор регулярок, а делает реальный парсинг, и в результате исправляет неправильно закрытые bbcode-теги и гарантирует на выходе well-formed фрагменты.

Модуль пришлось слегка поправить: удалить оттуда специфичные для его исходного приложения теги, а также сделать генерируемый HTML более семантически корректным (не удержался :-) ). Но в общем и целом, ушло на подключение bbcode минут эдак 15-20.

А вот с markdown'ом пришлось повозиться. Я как-то совсем не брал в расчет, что этот синтаксис сам по себе допускает свободное использование HTML, а я как раз не хочу давать этой возможности в форуме. Python markdown (который как раз, кажется, единственный markdown в Питоне), по идее имеет даже специальный параметр "safe_mode", с которым он не пропускает HTML. Но только делает он это совсем... мнэ-э-э... "неинтуитивно": вместо того, чтобы просто экранировать, он любой HTML-фрагмент заменяет на строчку "[HTML_REMOVED]". Крайне бесполезно, по-моему...

Я попробовал было сам escape'ить HTML перед markdown'ом, но это просто так не сработало, потому что в блоков кода markdown как раз экранирует HTML сам, и у меня он экранировался два раза. В итоге, ничего лучше я не придумал, чем дополните это... поиском регуляркой блоков кода и деэкранированием HTML обратно :-).

pattern = re.compile(r'(<code>.*?</code>)', re.S)
return re.sub(pattern, lambda match: match.group(1).replace('&amp;', '&'), value)

Заодно узнал, что в качестве параметра можно передавать не только строку типа "\1", но и свою функцию, что и видно в примере выше.

Разделение шаблонов

Я уже озвучивал проблему шаблонов в переносимом приложении:

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

Теперь задачка решена красиво, без всякого копирования. И как обычно в таких случаях думаешь: "И как это сначала не было очевидным?" :-)

Шаблоны форума, в которых лежит "мясо" — списки форумов/топиков/статей, паджинация, навигация — отделены от шапки через джанговское наследование шаблонов. Шапку с названием они наследуют от родителя. И вот этот вот родитель может быть разным. Сответственно, вместе с Cicero поставляется отдельный тестовый родительский шаблон ("cicero_test/base.html"), а при подключении в другой сайт, пользователю надо будет указать в настройках название своего базового шаблона для форума. Который в свою очередь будет скорее всего сам наследоваться от базового шаблона сайта, который уже есть, и подставлять в нужные места блоки форумных шаблонов.

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

{% extends "base.html" %}

То есть название шаблона-родителя зашито жестко. Но вместо строки легко можно использовать переменную:

{% extends template_base %}

Остается только передавать ее в шаблон. У меня этим занимается процессор контекста. Процессор — это маленькая функция, которая вызывается для каждого view, и передает туда какие-то общие переменные. Они используются в Django повсеместно для передачи таких вещей как "текущий юзер", "текущий язык" и т.д. Мой процессор выглядит так:

def default(request):
  from django.conf import settings
  return {
    'template_base': settings.TEMPLATE_BASE,
  }

Последнее замечание о том, как этот процессор подключается к проекту. Обычно это делается через настройки, но я стараюсь свести их к необходимому минимуму. Поэтому процессор контекста прописываются у меня в urlconf'е всем view, которым это надо (то есть всем).

Вообще, наверное многие заметили, что я считаю очень важным, и много времени трачу на то, чтобы при установке софта обеспечить эффект "plug-n-play" без траты мозгов пользователя на настройки.

Паджинатор

Реализовал еще и паджинатор — небольшой кусочек HTML'а для листания страниц. Такие вещи в Джанго делаются в виде пользовательских шаблонных тегов. О том, как он делается, я уже когда-то подробно рассказывал. Сейчас, к тому же, не пришлось писать собственно код выборки страниц, потому что generic views, на которых у меня построен форум, делают это автоматически.

Кроме того, я решил попробовать другой внешний вид. В любом паджинаторе присутствуют ссылки "вперед" и "назад", и они чаще всего и используются. Также очевидно полезны ссылки на последнюю и первую страницы. Но вот для произвольной навигации почему-то все вставляют несколько ссылок на соседние страницы. Я всегда не понимал, почему считается, что через две страницы мне должно быть удобнее ходить, чем через, скажем, 6. По идее, если и возникает ситуация "мне нужна страница N", то она с равной вероятностью может быть где угодно, не обязательно по соседству.

Поэтому я врисовал в паджинатор окошко, куда любую цифру просто можно вписать руками. Пока нравится. Да здравствует равноправие!

URL'ы страниц получаются такими: "/forum/123/?page=2". В тестовом форуме мне уже пожаловались, что это некрасиво. Красота — понятие субъективное, и хотя я тоже не прихожу в восторг от того, как это выглядит, но и в дрожь меня не бросает. Что для меня важнее, это чтобы в URL'ах соблюдался REST-принцип, который изначально был заложен в HTTP: каждый URL означает один конкретный ресурс. Таким ресурсом я считаю топик/форум целиком, поэтому номер страницы и выносится в параметры.

Причем довольно быстро (и даже неожиданно) такой выбор дал использовать очень простой HTML в паджинаторе:

<div class="paginator">
  <div class="links"><span class="previous">←</span><a href="?page=2" class="next">→</a> </div>
  <form action="./" method="get"><input type="text" name="page" value="1"> (2)</form>
</div>

Обратите внимание, что GET-форма, содержащая единственный input с параметром "page", позволяет вообще не писать никакого кода для ее обработки. Она сама сразу делает правильные URL'ы с "?page=N".

Мораль: уважайте HTTP, и вам воздастся!

Некоторые изменения с последней статьи остались "за кадром":

Кому интересно — читайте код!

Комментарии: 18

  1. wiz

    Ещё чуть-чуть и можно будет издавать книжку "освойте джанго за 21 пост в маниакальном веблоге" (=

  2. Efreeti

    из еще одной оговоренной функции

    Разве в Питоне нет интерфейсов?

  3. Денис Барушев

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

    Use Django's powerful, extensible and designer-friendly template language to separate design, content and Python code.

    Но есть один момент, который меня очень смущает, имя ему templatetags. В данном случа это templatetags/cicero.py

    Разве это не тот самый "an example of what not to do"? Ведь там по сути простейший шаблон должен быть, а не такая мешанина кода...

  4. Иван Сагалаев

    Разве в Питоне нет интерфейсов?

    Нет :-). В Питоне царствует "duck typing" (статья немножко нагружена спецификой в начале, но суть объясняет).

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

    Но есть один момент, который меня очень смущает, имя ему templatetags. В данном случа это templatetags/cicero.py

    Признаюсь, я ждал этого вопроса :-).

    Да, действительно, в теге в питоновском коде составляется HTML, и делается это вполне сознательно.

    Во-первых этот HTML не добавляет к структуре информации тега ничего презентационного. Фактически я предполагаю, что этот HTML можно отстайлить практически как угодно только CSS'ом, а значит рука дизайнера его и не должна касаться. Кроме того он маленький. И вот исходя из этих двух соображений я посчитал возможным оставить HTML прямо в теге и не городить еще целый отдельный файл. Просто потому что в данном конкретном случае так проще. Если мою видение дальше изменится — отрефакторю в шаблон.

    В самом Django тоже есть похожее поведение: контролы форм генерятся Питоновским кодом. Потому что иметь целый шаблон, управляющий отображением <input type="text"> — это чересчур.

    Есть в "Дзене Питона" такой момент:

    Although practicality beats purity.

    Вот это такой случай.

  5. Денис Барушев

    Не убедительно :) А может все-таки:

    Readability counts.
    Special cases aren't special enough to break the rules.

    Вобщем, все на совести программиста. Но если есть возможность для шаблонного тега написать шаблон, то спорить, собственно, нечего.

  6. Иван Сагалаев

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

    Собственно вот: http://softwaremaniacs.org/blog/2006/02/17/include-templates-with-vars/

  7. Deepwalker

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

    Не убедительно :) А может все-таки:

    Посмотрите код джанго : )) Это не очень практично - код функции отрисовки (то есть функции, которая предназначена сама по себе генерировать html код), разносить в два разных места. Это имело бы смысл, если бы там был действительно большой html код.

  8. Max Ischenko

    Задачка о подключении фильтров "спровоцировала" меня написать статейку об использовании точек расширения setuptools: http://www.developers.org.ua/archives/max/2007/03/14/plugins-with-setuptools/

    Наверное, в данном контексте это overkill, но для плагинов авось пригодится. ;)

  9. Иван Сагалаев

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

    Ну туда можно ввести вообще много всякой ерунды. Но это будет честная 404. Я подумаю про <select>... Чисто функционально он мне кажется действительно лучше, а чисто эстетически — нет :-).

    Вот что туда нужно — так это permanent-ссылку на последнюю страницу, чтобы ее в закладки можно было класть.

  10. Deepwalker

    Ну туда можно ввести вообще много всякой ерунды. Но это будет честная 404.

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

  11. Tema

    Кто мешает делать простую проверку JS на существование страницы и на целочисленный формат ввода?

  12. Alex Lebedev

    Коллеги, а как select будет выглядеть при при возможносте выбора из 192 страниц?

    Я считаю что это достаточно часто распространенный случай, и стоит подумать о нем заранее.

  13. Гость
    • проискивает все .py-файлы в папке “filters”
    • импортирует их как модули
    • ищет в них функцию конвертации в HTML с оговоренным названием (”to_html()”)
    • если находит, регистрирует эту функцию в качестве фильтра; название фильтра достается из еще одной оговоренной функции — “name()”

    Классно:-)
    Ломаем форум. Заливаем в эту директорию какой-то .py файл, специально под этот форум написанный и привет:-).

    Имхо, названия filters должны храниться в db.

  14. Иван Сагалаев

    А как можно "залить в директорию"?

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

    Если же речь идет о каком-то фильтре, который злоумышленник написал и просит автора форума его установить, то прописывание в БД этому не помешает: раз автор решил что-то поставить, то он пропишет его и в БД тут же.

  15. Deepwalker

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

  16. bobuk

    Слушай, а чего было не использовать модуль imp? Оно же решило бы тебе проблему с name()

  17. Иван Сагалаев

    А я про него ничего не знал :-).

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

  18. Tema

    Чето первое же тестовое сообщение в форум повалило сервер, сообщение об ошибке как было сказано отослано администратору =).

Добавить комментарий