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

И вот как раз импорту я и посвящаю последний подробный технический обзор в серии Cicero. Дальше форум будет развиваться уже по мелочам и без постописательства.

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

Импорт, вообще, более точно стоит назвать "миграцией", которая состоит из нескольких операций:

Для всего этого я сделал отдельное от Cicero приложение. Так это лучше укладывалось в голову при программировании, а также позволяет не таскать этот кусок к себе тем, кому импорт не нужен.

Импорт контента

Структурно импорт оказался очень простым. Движок PunBB, оказывается, разделяет форум на очень похожие модели: там тоже есть таблицы форумов, топиков, статей и пользователей. Поля только немного другие. Поэтому сам алгоритм выливается в "сделать select, создать по результатам новые записи".

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

./manage.py import_punbb <databasename>

В коде это выглядит примерно так. В папке приложения создается файл management/commands/import_punbb.py, в котором определяется класс Command:

from optparse import make_option

from django.core.management.base import BaseCommand, CommandError

class Command(BaseCommand):

  # Описание опций и подсказок
  option_list = BaseCommand.option_list + (
    make_option('--user', action='store', dest='user', default=None,
      help=u'Имя пользователя'),
    make_option('--password', action='store', dest='password', default=None,
      help=u'Пароль'),
    make_option('--host', action='store', dest='host', default='localhost',
      help=u'Хост'),
    make_option('--port', action='store', dest='port', default=None,
      help=u'Порт'),
    make_option('--real-charset', action='store', dest='real_charset',
      help=u'Игнорировать заявленную кодировку в таблицах исходной БД и \
             считать данные записанными в указанной кодировке'),
  )
  help = u'Импортирует содержимое PunBB-форума в БД Cicero'
  args = '<database>'

  # Основная функция обработки команды
  def handle(self, *args, **options):

    # Проверка валидности аргументов
    if len(args) != 1:
      raise CommandError('Требуется один параметр -- имя БД PunBB')
    kwargs = {
      'host': options['host'],
      'db': args[0],
    }
    if options['user'] is not None:
      kwargs['user'] = options['user']
    if options['password'] is not None:
      kwargs['passwd'] = options['password']
    if options['port'] is not None:
      kwargs['port'] = int(options['port'])
    self.real_charset = options.get('real_charset', None)
    if not self.real_charset:
      kwargs.update({
        'use_unicode': True,
      })

    # Cоединение с MySQL
    import MySQLdb
    self.connection = MySQLdb.connect(**kwargs)

    # Выполнение всех операций в одной транзакции
    from django.db import transaction
    transaction.enter_transaction_management()
    try:
      transaction.managed(True)
      self.import_forums()
      self.import_topics()
      self.import_articles()
      transaction.commit()
    finally:
      transaction.rollback()
      transaction.leave_transaction_management()

# сами функции импорта не очень интересные и опущены для краткости

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

Еще один момент, на котором остановлюсь — это опция "--real-charset". Так вышло, что когда-то давно, когда я переезжал на новый хостинг, и перетаскивал с собой БД, я очень долго мучался, пытаясь заставить MySQL хранить utf-8 данные, а форум — их корректно показывать. Мучился, потому что я тогда ничего не знал про MySQL в этом смысле. Так вот теперь оказалось, что тогда у меня получилось все криво: данные в таблицах закодированы в utf-8, но зато сами таблицы думают про них, что это — latin1. В итоге, если положиться на MySQLdb, чтобы он сам раскодировал данные, они приходят очевидно побитые.

Поэтому я и придумал опцию --real-charset, в которую пользователь может написать кодировку, которую ему надо. В этом случае я забираю из БД не юникод, а просто байты и раскодировку провожу уже вручную, decode'ом:

for id, forum_id, subject, posted in cursor.fetchall():
  if self.real_charset:
    subject = subject.decode(self.real_charset, 'replace')
  topic = Topic.objects.create(
    forum_id=self.forum_ids[forum_id],
    subject=subject,
  )

Редиректы

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

Для этой цели в приложении cicero_punbb_import определены несколько моделей, которые фактически хранят соответствие старых ID форумов, топиков и статей их новым импортированным копиям.

class ForumImport(models.Model):
  id = models.IntegerField(primary_key=True)
  forum = models.ForeignKey(Forum)

class TopicImport(models.Model):
  id = models.IntegerField(primary_key=True)
  topic = models.ForeignKey(Topic)

class ArticleImport(models.Model):
  id = models.IntegerField(primary_key=True)
  article = models.ForeignKey(Article)
  user = models.ForeignKey(UserImport)

Соответственно при импорте создаются не только записи в таблицах самого Сicero, но и вот эти, служебные. Дальше обслуживание редиректов — дело пары вьюх, которые по старым IDшникам ищут новые и возвращают HttpResponseRedirect:

# вспомогательная функция
def get_object_by_int(model, pk):
  try:
    return model.objects.get(pk=int(pk))
  except (ValueError, model.DoesNotExist):
    raise Http404

# вьюха
def redirect_forum(request):
  fi = get_object_by_int(ForumImport, pk=request.GET.get('id'))
  return HttpResponseRedirect(fi.forum.get_absolute_url())

Вспомогательная функция — замена стандартной get_object_or_404. В случае с джанговскими URL'ами базовый тип параметров в них обычно проверяется еще в urlconf'ах регулярками, а здесь все передается в старой манере — через GET. И вот через них в первые минут 15 работы форума я получил "привет" в виде запросов от реферальных спамеров в духе "viewtopic.php?id=http://...viagra...". Эта штука ломала выборки из базы, потому что URL'ы про виагру к целым числам автоматически не приводятся.

Кстати, еще одно мое недовольство PunBB: на такие спамные запросы он отвечает с кодом 200, а не 404, и их из-за этого трудно вырезать из статистики.

Привязка статей к аккаунтам

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

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

Для этого есть соответственно моделька, которая хранит старые данные юзера и поле со старым ID юзера в импортированных статьях:

class UserImport(models.Model):
  id = models.IntegerField(primary_key=True)
  username = models.CharField(max_length=200)
  password = models.CharField(max_length=40)

class ArticleImport(models.Model):
  id = models.IntegerField(primary_key=True)
  article = models.ForeignKey(Article)
  user = models.ForeignKey(UserImport)

А также стандартный набор из URL'а, вьюхи и формочки, которые эти постинги обрабатывают. Там никакой магии нет. Единственное, что стоит подчеркнуть, что с хранением паролей в PunBB все хорошо: они там в виде sha1-хешей.

urlconf

Для полноты картины хочу описать гибкость urlconf'ов, которые все это обслуживают.

Что нетипично, но абсолютно нормально для джанговских приложений, у cicero_punbb_import urlconf'ов два. Один для привязки статей, другой — для редиректов. Сделано так для тех случаев, когда форум на сайте при смене движка меняет еще и корень URL'а, и в этом случае привязку статей возможно захочется привязать к новому корню, а редиректы конечно должны остаться на старом. Я, впрочем, сам этой идеей не воспользовался, и у меня сейчас главный urlconf проекта выглядит в части форума так:

(r'^forum/', include('cicero_punbb_import.redirect_urls')), # редиректы
(r'^forum/', include('cicero.urls')),                       # собственно форум
(r'^forum/', include('cicero_punbb_import.urls')),          # привязка статей

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

Ну и напоследок о том, как выглядят url'ы для редиректов:

urlpatterns = patterns('',
  (r'^viewforum\.php$', views.redirect_forum),
  (r'^viewtopic\.php$', views.redirect_topic),
)

Это то самое, за что джанговский urlconf на регулярных выражениях стоит любить: нет никакой жестко заданной структуры типа {controller}/{id}/{action}, можно описать все что угодно, даже PHP-приложение!

Итог

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

Также немного осталось за педелами первой версии, но будет доделано когда-то позднее:

Мне кажется, хорошо получилось :-). Прошу осваиваться и непременно сообщать о багах прямо там в форуме для тестов.

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

  1. Arcady Chumachenko

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

  2. wiz

    Осталось ещё блог перевести..

  3. zencd.livejournal.com

    не отображается кол-во сообщений в топике — неудобно… или непривычно?

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

    Осталось ещё блог перевести..

    Как только markdown-extra на Питоне появится :-)

    не отображается кол-во сообщений в топике — неудобно… или непривычно?

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

  5. LMZ

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

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

    Да, реализован. Вот тут подробно: http://softwaremaniacs.org/blog/2007/04/19/read-messages/

  7. Роман Парпалак

    Иван, а почему Вы отказались от PunBB?

  8. Александр Кошелев

    Могу предположить, что just for fun и потому что он не на джанге;)

  9. Stoune

    Иван вы оправдали звание маньяка. Я отпуск бы потратил как раз на отдых от компъютера, где-то в глуши.

  10. Тормоз

    Отлично! Вот такой минимализм я обожаю. Замечательный интерфейс. И в блоге стало по-другому. Прямо завидую маленько :) Молодчина, что смог выделить время на всё это. Приятно видеть.

  11. Тормоз

    Прошу прощения за второй комментарий, предыдущие прочел только что. Хочу написать про PunBB, что он, конечно, быстр, но код написан ой-ё-ёй... Ни намека на разделение представления и содержания, всё в перемешку. Намучался я с ним.
    Пока для PHP мой однозначный выбор - Vanilla.

  12. syncsync

    Проблема в том что без дат (создание темы, последний ответ) форум не воспринимается как форум. Да и автора темы сразу видеть хочется. Перебрали вы сильно с минимализмом Иван в этот раз!

  13. Игорь Бойко | Блог о заголовках

    Не отображается количество сообщений в теме.

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

    Проблема в том что без дат (создание темы, последний ответ) форум не воспринимается как форум

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

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

    Иван вы оправдали звание маньяка. Я отпуск бы потратил как раз на отдых от компъютера, где-то в глуши.

    Хех :-). Мы, вот, сейчас с друзьями сидим в одном хорватском курортном кафе, и я таки нашел тут WiFi, чтобы на комментарии ответить. Да, я маньяк :-)

  15. alex3d.livejournal.com

    imho междустрочный интервал в списке тем побольше надо сделать
    или ещё как-то разделить темы визуально

  16. Андрей

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

    Ну, как же - готовая утилита миграции с punbb на cicero. В репозиторий!

  17. Михаил

    А есть какая-то веская причина по которой для редиректов не использован штатный django.contrib.redirects?

  18. HRMan

    Да, даты обязательно нужны! Без них никак! И интервал междустрочный, про которые говорил alex3d надо действительно увеличить - юзабильнее будет, хотя это тоже моё имхо.

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

    А есть какая-то веская причина по которой для редиректов не использован штатный django.contrib.redirects?

    Потому что мне надо переводить одни IDшки в другие, а стандартный редиректор так не умеет, он просто один статический URL на другой меняет.

  20. http://schors.livejournal.com/

    А предполагается ли публично выкладывать форум? Ну чтобы обычные люди могли пользоваться ;)

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

    Так он, в общем-то, "выложен": svn://softwaremaniacs.org/cicero/

    Превращать его в цветущий проект у меня, к сожалению, никакого времени нет, но забрать и использовать у себя его может кто угодно. В одном месте уже даже установлен: http://pyobjc.ru/forum/.

  22. http://schors.livejournal.com/

    Хм.. Хм.. Хм.. Ну ладно, да, посмотрим :) Но всё равно спасибо ;)

    P.S. Ищу для себя небольшие общеупотребительные success strories :) на питоне и django. В этой нише как-то всё очень сыровато...

  23. Imbolc

    А конверт в другую субд (из мускуля в постгрис) предусмотрен?
    И чего-то ссылки не работают и на сам форум и на конвертор. Что-то изменилось?

    up: и еще чего-то не комментит в блог через опен-ид:
    Error: please fill the required fields (name, email).
    Или это фича странная? :)

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

    А конверт в другую субд (из мускуля в постгрис) предусмотрен?

    Очень странный вопрос :-). Поскольку вся запись в новую базу делается средствами Джанго, то очевидно, какая СУБД в настройках прописана, туда и будут все переезжать.

    И чего-то ссылки не работают и на сам форум и на конвертор. Что-то изменилось?

    Да, я переключился с svn на bzr. В ближайшее время все опубликую и обновлю ссылки.

    Error: please fill the required fields (name, email).
    Или это фича странная? :)

    Это убогость WordPress'а с OpenID плагином :-(

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