Django: полиморфные связи между базами данных

9 ноября 2010

Внезапно Винни-Пух остановился и нагнулся к земле.
— В чём дело? — спросил Пятачок.
— Очень странная вещь,- сказал медвежонок.- Теперь тут, кажется, стало два зверя. Вот к этому — Неизвестно Кому — подошёл другой — Неизвестно Кто, и они теперь гуляют вдвоём.

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

Проблема

Изначально в проекте, помимо прочих, было две таблицы: одна таблица, скажем, Статьи и другая таблица, скажем, Сообщения. Сообщение могло быть связано с какой-то Статьей, чтобы было понятно, о чем идет речь. В новой версии проекта было принято решение добавить еще одну таблицу – Продукты и расширить таблицу Сообщений так, чтобы можно было писать сообщения и про Продукты. То есть вместо простого Foreign Key из одной понятной таблицы нужно было сделать не то Conditional Foreign Key, не то Polymorphic Foreign Key на Неизвестно Какую Таблицу. Нужно заметить, что сообщение может относиться только к чему-то одному, оно не может быть одновременно про Статью и Продукт.

Решения

Решений напрашивается несколько: грязное (добавить еще один Foreign Key в Сообщения), хлопотное (убрать нафиг вообще ключ Статьи из Сообщения и сделать 2 новых таблицы, одна из которых будет связывать ключи Статьи и Сообщения, а другая – Продукта и Сообщения) и хитрое (сделать 2 поля, одно из которых будет говорить, какая база имеется в виду, а второе – хранить ключ в этой базе, к модели же добавить свойство, которое будет возвращать запись из нужной базы). Я уже было совсем решился самостоятельно реализовать последнее, но мне очень вовремя подсказали, что в Django имеется готовый механизм для таких случаев, состоящий из двух компонентов: Content Types Framework и Generic Relations.

Content Types Framework

Это очень простая система, которая в специальной таблице contenttypes хранит информацию о типах моделей: имя приложения, имя модели и имя типа – нечто удобное для восприятия программистом. По умолчанию нужное приложение (django.contrib.contenttypes) уже включено в INSTALLED_APPS, так как эта система используется админкой Django. Импортируйте ContentType из django.contrib.contenttypes.models, и тогда для каждой модели вашей системы вы сможете делать следующие три операции:

  1. получать объект ContentType по имени приложения и имени модели:
    user_type = ContentType.objects.get(app_label="auth", model="user")

    или просто по модели

    user_type = ContentType.objects.get_for_model(User)

  2. получать модель из полученного ContentType:
    model = user_type.model_class()

  3. получать нужный вам объект через model.objects.get( ) или через
    user_type.get_object_for_this_type(username='Piglet')

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

Generic Relations

Итак, в таблице Сообщений у нас есть 2 поля, первое – тип модели, или, как я его назвал, тип предмета (к которому относится сообщение), теперь речь пойдет о втором – о ключе из соответствующей базы. Стандартное решение выглядит так:

item_type = models.ForeignKey(ContentType, blank=True, null=True, verbose_name="Type")
item_id = models.PositiveIntegerField(blank=True, null=True, editable=True, verbose_name=u"Link")
item = generic.GenericForeignKey('item_type', 'item_id')

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

  • нужно импортировать generic из django.contrib.contenttypes;
  • имена полей для связи могут быть любые, но по умолчанию используются content_type и object_id;
  • вы не можете дать GenericForeignKey verbose_name для админки, поскольку item, собственно говоря, за поле не считается. Как с этим бороться, я расскажу позже.

Теперь полю item вы можете присваивать буквально что угодно. Это круто с одной стороны и не очень круто с другой. Понятно, что если теперь в проект добавится таблица, допустим Бяки, то проблем с расширяемостью не будет никаких.

item = Woozles.objects.get(id=700)

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

Еще одна особенность – по GenericForeignKey нельзя делать фильтры. Я, например, это решил так: Messages.objects.filter(item_type=type_id, item_id = item_id).

Кроме прямой связи (Сообщение -> Продукт) можно делать и обратную, то есть к Продукту можно добавить коллекцию его Сообщений. Для этого в класс модели Продукта добавляется конструкция 

messages = generic.GenericRelation(Message, content_type_field=’item_type’, object_id_field=’item_id’)

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

Полезности

А теперь, после необходимого краткого конспекта из документации Django, перейдем к тому, что в нем, собственно, отсутствовало.

Миграция

Для того чтобы перейти на новую структуру данных, я воспользовался South. Будем считать, что изначально ключ Статьи хранился в Сообщении в поле post. Миграция проходила в 4 этапа:

  1. 0001 – делаем начальную миграцию со «снимком» текущей модели;
  2. 0002 – добавляем поля item_type, item_id, item (миграция схемы);
  3. 0003 – преобразование данных текущей базы (миграция данных, необратимая, так как Продукты ранее отсутствовали)
  4. def forwards(self, orm):
            # определяем тип модели Статья
            post_type = orm['contenttypes.ContentType'].objects.get(app_label="posts", model="post") 
            for message in orm.Message.objects.all():
                # кроме Статей раньше ничего не было
                message.item_type = post_type
                # прямое копирование ключа
                message.item_id = message.post
                message.save()
    
    def backwards(self, orm):
            raise RuntimeError("Cannot reverse this migration: Product id's will be lost");
    
  5. 0004 – удаляем ненужное поле post (миграция схемы).

Шаблоны

В моем случае пришлось перелопатить изрядное количество шаблонов и сделать их более универсальными. Здесь советы давать трудно, но суть изменений свелась к тому, что нужно проверять, что же сейчас за item имеется в виду – Статья или Продукт, а в ряде мест добавить тип предмета как параметр в url. Последнее я решил путем добавления свойства contenttype_id в модели Статьи и Продукта, которое возвращает id типа, это оказалось наилучшим решением в моем контексте.

Админка

Тут я столкнулся с двумя проблемами. Первая, покрупнее – редактирование записей в связанных базах. В Django есть стандартное решение для редактирования записей типа Статья или Продукт: можно легко просматривать и добавлять связанные с ними Сообщения через generic.GenericTabularInline, вот так:

class LetterInline(generic.GenericTabularInline):
    model = Letter
    ct_field = 'item_type'
    ct_fk_field = 'item_id'

class PostAdmin(admin.ModelAdmin):
    inlines = [
        LetterInline,
    ]

admin.site.register(Post, PostAdmin)

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

Изначально я думал наворотить и обрабатывать 2 выпадающих списка в редакторе Сообщения: первый – уместные типы предметов, а второй, в зависимости от его состояния, – список Статей или Продуктов.

Плохая новость: я не смог найти решение, которое мне бы подошло. Хорошая новость лично для меня была в том, что в моем случае для Сообщения достаточно было просто показать тип и название предмета, к которому оно имеет отношение.

Это решилось тоже не сразу и «имело свои моменты»:
1. Как я уже говорил, item за поле не считается и админка его молча игнорирует. Если вы впишете его в список полей руками, то в ответ получите грязное ругательство, что, мол, нет такого в форме. Схитрить можно следующим образом: его нужно обозначить как «только для чтения»:

class MessageAdmin(admin.ModelAdmin):
    fieldsets = [
        (None,               {'fields': ['body', 'item_type', 'item']}),
        ('Date information', {'fields': ['pub_date'], 'other': ['collapse']}),
    ]
readonly_fields = ['item', 'item_type']
exclude = ('item_id',)

Поле item_id лучше убрать от греха подальше, но я встречал (хотя сам не пробовал) подсказчик для таких полей.
В list_display вы можете спокойно указывать item и item_type и упоенно созерцать имя типа и заголовок предмета в списке сообщений.

2. Вторая неприятность, которая, в общем, является следствием перфекционизма, – это то, что полю item нельзя дать внятного названия (verbose_name). Это решилось добавлением в класс MessageAdmin такого метода:

    # Назначение этой функции – вывод правильного имени поля item
    def item_column(self, obj):
        return obj.item if obj.item else None
    item_column.short_description = u'Предмет'

Затем item в fieldsets и list_display заменяется на item_column – и вот оно, счастье.

Я не слишком большой знаток Django, поэтому советам и предложениям буду рад.