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

При заведении в системе новых пользователей, никто автомагически не будет для них создавать записи Profile’ов, и обращение user.cicero_profile будет вызывать exception. Поэтому, надо их где-то создавать. Я для этого использую middleware, которое вешается где-нибудь после авторизации и для каждого пользователя, залогиненного на сайт, смотрит, есть ли у него профиль, и создает если надо. Минусов я вижу два:

Сегодня я переписал эту логику с помощью дескрипторов и спешу поделиться. Но должен сразу предупредить — это уже суровая магия :-)

Откуда берутся ссылки

При описании модели Profile в моем приложении, связанной с моделью User в системном приложении "auth", в самой модели User тут же появляется ссылка на профайл. Откуда она там берется? Это работа интересной питоновской штуки под названием "метакласс", которую Джанго использует на всю катушку.

Питон — язык динамический, и в нем описанный в программе класс создается непосредственно в рантайме как самостоятельный объект. Это отличается от, например, C++, где описание класса имеет смысл только на этапе компиляции и статично описывает структуру будущих объектов этого класса. В Питоне же во время создания класса на этот процесс можно еще и повлиять. Для этого создается "метакласс" — отдельный объект, которому скармливается подготовленный класс, и он может делать с ним все что угодно: добавлять, удалять и менять поля и методы. Вот, кому интересно, очень загрузочная статья по этому поводу.

В Джанго все модели БД проходят через метакласс ModelBase, который делает такие вещи:

Более подробно этот процесс можно изучить в статье Малколма Трединника.

Что представляет собой ссылка

Итак, ссылочные поля добавляют в модели, которые связывают, атрибуты для доступа в обе стороны: от родительских объектов к детям и наоборот. Эти атрибуты — питоновские дескрипторы — такие интересные объекты, которые позволяют определить операции чтения их значений и установки им новых значений (делфисты узнают в этом объектные property).

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

class SingleRelatedObjectDescriptor(object):
  def __get__(self, instance, instance_type=None):
    # - составить запрос в ORM для получения связанного объекта
    # - закешировать его внутри себя для последующего использования 
    # - вернуть объект

  def __set__(self, instance, value):
    # поставить id родительского объекта, к которому привязан дескриптор, в
    # нужное поле переданного зависимого объекта

AutoOneToOneField

Возвращаемся к Cicero. Есть джанговский класс User и есть связанный с ним через OneToOneField класс Profile. В процессе создания Profile создается атрибут User.cicero_profile — объект-дескриптор. Соответственно для каждого объекта класса User при попытке взять user.cicero_profile будет вызываться метод __get__ этого дескриптора. Который просто выполняет запрос в духе select cicero_profile.id, ... where user_id = .... И вызывает исключение, если профайла в базе нет.

Чтобы это изменить, я просто написал собственный дескриптор — наследник стандартного, у которого метод __get__ работает так:

try:
  # получить объект
except ОбъектНеНайден:
  # создать объект
return объект

Ну и чтобы заставить OneToOneField вешать на User'а не стандартный дескриптор, а мой, пришлось и от самого поля сделать наследника, который переопределяет метод contribute_to_related_class, назначающий дескриптор.

Код вышел довольно просто, потому что вся идея уже была написана в джанговских классах, от которых я наследовался:

from django.db.models import OneToOneField
from django.db.models.fields.related import SingleRelatedObjectDescriptor

class AutoSingleRelatedObjectDescriptor(SingleRelatedObjectDescriptor): # this line just can't be too long, right?
  def __get__(self, instance, instance_type=None):
    try:
      return super(AutoSingleRelatedObjectDescriptor, self).__get__(instance, instance_type)
    except self.related.model.DoesNotExist:
      obj = self.related.model(**{self.related.field.name: instance})
      obj.save()
      return obj

class AutoOneToOneField(OneToOneField):
  '''
  OneToOneField, которое создает зависимый объект при первом обращении
  из родительского, если он еще не создан.
  '''
  def contribute_to_related_class(self, cls, related):
    setattr(cls, related.get_accessor_name(), AutoSingleRelatedObjectDescriptor(related))
    if not cls._meta.one_to_one_field:
      cls._meta.one_to_one_field = self

В итоге:

P.S. Питон — мощь!

P.P.S. Опенсурс — мощь!

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

  1. Максим Деркачев

    Да, классно придумал!

  2. FX Poster

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

  3. bw

    А при удалении пользователя, этот профиль так же будет удаляться или нет?

    ..bw

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

    Да, будет. Django подчищает все зависимые записи при удалении.

  5. Murkt

    В Питоне же во время создания класса на этот процесс можно еще и повлиять. Для этого создается “метакласс”

    Я думал по-русски об этом написать, но что-то никак не придумывается нормального примера, на котором можно было бы объяснить...

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

    Книжки... Мне всегда сложно про это отвечать :-). Сам я начал изучать с Dive Into Python. Она хорошо написана, но местами уже сильно устарела (особенно в описании объектов). И кроме того, к моменту, когда я начал питонить, я уже давно и много программировал. А просто учить язык и учить N-ный язык — это большая разница.

    Поэтому мой совершенно честный совет будет такой: примкнуть к какому-нибудь большому питоновскому проекту, следить за разработкой и читать исходники. Это — лучшая школа.

  7. Danila Shtan

    Иван, заметка очень интересная (вообще, заглядывать во внутренности нетривиальных вещей интересно всегда). Однако, меня мучает один страшный вопрос.

    А почему нельзя повесить создание user.cicero_profile на user.save()?

    По-моему, это логичнее.

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

    Потому что юзеры в проекте создаются (и save()'ятся) далеко не только в коде форума:

    • есть другие приложения (в частности - админка)
    • есть юзеры, уже заведенные в системе к моменту установки туда форума

    А раз он их не создает всех явно, то и save() ему негде вызывать.

  9. qewerty

    Django подчищает все зависимые записи при удалении.

    А что делать с постами этого пользователя? Можно конечно разрешить null значения, но что тогда показывать об авторе поста.

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

    А пользователи как раз поэтому и не должны удаляться, в общем-то. Зачем? Отключить их можно через флажок is_active, а удалять не нужно.

    Единственно, у меня будет код, который будет сливать двух пользователей в одного (если ими владеет один человек). Но там статьи будут апдейтиться так, чтобы ими владел тот пользователь, который в результате слияния останется.

  11. Василий

    Ну и чтобы заставить OneToOneField вешать на User’а не стандартный дескриптор, а мой, пришлось и от самого поля сделать наследника, который переопределяет метод contribute_to_related_class, назначающий дескриптор.

    но как джанго поймёт что вешается именно наследник ??

    так вот я немного непонимаю как сделать чтобы это заработало ? написать в User.Meta что то типа cicero_profile = ????? было бы элегантно...

    ты вроде бы упустил этот момент...

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

    Я не очень понимаю, что именно не понятно :-). В цитате ровно содержится ответ: я делаю наследника от OneToOneField под названием AutoOneToOneField, и именно и вешает динамически на User нужный дескриптор.

  13. Василий

    Проблему решил.
    Я не догнал что надо переопределить поле в модели не простым OneToOneField а уже нашим, переопределённым. в другой ситуации наверное и очевидно было бы, но я так запутался в исходниках django.db. С виду вроде бы поля действуют в пределах модели, а джанго сам добавляет related поля в related модель, поэтому я пытался явно определить в модели relatedfield и так далее..

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

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

    в целом удачи тебе, спасибо что ты есть )

  14. [...] Речь вот об этом: http://softwaremaniacs.org/blog/2007/03/07/auto-one-to-one-field/ [...]

  15. Dimas

    Подскадите а в чем может быть прикол?

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

    модель
    from django.contrib.auth.models import User
    from fields import AutoOneToOneField

    class Profile(models.Model):
        user = AutoOneToOneField(User, primary_key=True)
    ...
    

    все падает еще на проверке

    $python manage.py validate
    ...
    File "fields.py", line 21, in contribute_to_related_class
    if not cls._meta.one_to_one_field:
    AttributeError: 'Options' object has no attribute 'one_to_one_field'
    
  16. Dimas

    единственное разумное что пока придумал это явно проверять наличие атрибута

    def contribute_to_related_class(self, cls, related):
        setattr(cls, related.get_accessor_name(), AutoSingleRelatedObjectDescriptor(related))
    if not hasattr(cls._meta, "one_to_one_field") or not cls._meta.one_to_one_field :
            cls._meta.one_to_one_field = self
    

    извиняюсь что немного не в тему

  17. Я хотел просто писать request.user.profile и сразу получать нужный профиль. Этого удалось добиться применением двух известных трюков - Upcast и AutoOneToOneField.

  18. sk dispatch_uid - указывайте.@receiver(post_save, sender=User, dispatch_uid="gV3AQD1y3H6laRX5mx94KDWeqYrL9c0V")def create_profile(sender, instance, created, **kwargs): if created: UserProfile.objects.get_or_create(user=instance)Также вот это почитайте для автоматизации создания OneToOne связей (полагаю у вас там one to one между юзером и его профилем).

  19. Mikhail Korobov

    Dimas, там баг в библиотеке, см. https://bitbucket.org/kmike/django-annoying/changeset/7d7237a708ab

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