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

Но я не виноват — это все Django! Там так легко и приятно оформлять удаленные объекты с помощью менеджеров моделей, что это практически само собой потянуло за собой весь интерфейс :-).

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

Модель

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

class ArticleManager(models.Manager):
  def get_query_set(self):
    return super(ArticleManager, self).get_query_set().filter(deleted__isnull=True)

class DeletedArticleManager(models.Manager):
  def get_query_set(self):
    return super(DeletedArticleManager, self).get_query_set().filter(deleted__isnull=False).order_by('-deleted')

class Article(models.Model):
  # ...
  deleted = models.DateTimeField(null=True, db_index=True)

  objects = ArticleManager()
  deleted_objects = DeletedArticleManager()

Метод get_query_set, который я переопределяю, вызывается всегда, когда начинает составляться запрос к модели, и по умолчанию возвращает запрос с пустым условием, который, соответственно, отдает все записи таблицы. В своем менеджере я добавляю свое условие, отфильтровывающее все deleted-записи. Этот менеджер я вешаю в модель, традиционно называя его "objects", и он становится основным менеджером. В том смысле, что работает при любой попытке доступа к ее записям. Не только, если его позвать явно через "Article.objects.какая-то-фильтрация", но и если к статьям, например, обращаться из топика: "topic.article_set.all()". В последнем случае удаленные статьи тоже будут отфильтрованы.

Также я нарисовал и второй менеджер, в который попадают уже только удаленные объекты. Он нужен специально в тех местах, где я собираюсь показывать список удаленных статей, чтобы их можно было при случае восстановить. Этот менеджер удаленных статей еще и сортирует их по полю "deleted". Теперь должно быть понятно, почему признак "deleted" — это не BooleanField, а DateTimeField, в который записывается текущее время при удалении. Таким образом получается и признак (заполнено / не заполнено), и удобная сортировка.

Точно такой же трюк проделывается и с топиками.

Были тут, правда, одни грабли. Есть у меня в программе место — проверка прочтённости — где таблички статей и топиков опрашиваются не через джанговский API, а напрямую SQL'ом, и соответственно никакие менеджеры на них не влияют. Туда пришлось слазить вручную и проставить " and deleted is null" в пару мест. Причем, я пока не могу придумать способа, как таких ошибок принципиально избегать.

Интерфейс

Теперь ко всей этой модели удаленных записей нужен интерфейс. С точки зрения пользователя это вот такая кнопка в каждой статье, которую он может редактировать:

<form action="{% url cicero.views.article_delete article.id %}" method="post"><button>Удалить</button></form>

Практика показывает, что реализация кнопки "Удалить" — больное место многих веб-систем, поэтому я хочу подробно рассказать, почему она сделана именно так, а не иначе.

Во-первых, это не ссылка (<a>). Потому что ссылка — это ссылка на другой документ, ее можно открыть в новом окне и запомнить в закладках, и все это бессмысленно для действия "Удалить". Кроме того, ссылку браузер открывает GET'ом, который в HTTP не должен изменять состояния системы. На практике это чревато разными краевыми проблемами. Например какой-нибудь веб-акселератор, предварительно читающий все ссылки на странице, будет автоматически стирать ваши статьи. Или, приводя любимый пример моего коллеги, какой-нибудь злой негодяй может стереть вашу статью, заманив вас на свою страницу, где будет картинка с src="адрес удаления вашей статьи". Поэтому ссылка по возможности не должна вести на прямое действие.

Однако часто ссылка "Удалить" легальна, если она ведет на отдельную страницу, на которой уже в свою очередь нарисована форма с вопросом "Правда удалить?" Однако реализовав удаление с возможностью восстановления, я имею роскошь не вешать интерфейс подтверждения удаления вообще. Почему роскошь? Грамотные юзабилисты считают, что алерты с переспрашиваниями — это очень плохой интерфейс. Встревая в действия пользователя, они бесят и отвлекают от задуманного, что быстро приводит к тому, что пользователь привыкает в местах, где все время вылезают алерты, нажимать на "правильную" кнопку автоматически, что уничтожает смысл алерта как таковой. Безвозвратное удаление — одно из немногих мест, где алерт оправдан, как хоть какая-то защита от случайного удаления. Но если есть способ лучше — а именно система undo — то стоит делать именно его.

Вот. А то, что кнопка выглядит "некрасиво", легко исправляется: ее можно легко заменить в шаблоне на <input type="image">. Или, если сердцу милее подчеркнутый текст, то кнопку вполне можно так стилизовать.

View

Вьюха, обрабатывающая удаление, довольно простая. Единственная интересная логика, которая там есть — это автоматическое удаление топика, когда удалены все статьи:

@login_required
def article_delete(request, id):
  article = get_object_or_404(Article, pk=id)
  if not request.user.cicero_profile.can_change(article):
    return HttpResponseForbidden('Нет прав для удаления')

  # Удаление простановкой deleted
  article.deleted = datetime.now()
  article.save()

  # Остались ли статьи в топике -- article_set.count не видит удаленных
  if article.topic.article_set.count():

    return HttpResponseRedirect(reverse(topic, args=(article.topic.forum.slug, article.topic.id)))
  else:

    # Если статей нет, топик тоже удаляется
    article.topic.deleted = datetime.now()
    article.topic.save()

    return HttpResponseRedirect(reverse(forum, args=(article.topic.forum.slug,)))

Undelete

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

Механика тут простая. Нужна view, которая будет выводить статьи с признаком deleted, отсортированные в обратном порядке. Благо менеджер для этой задачи я к модели я уже подвесил — Article.deleted_objects. Сама view реализована на базе стандартной object_list с парой предварительных операций. Также интересно, что она работает в двух режимах: персональном и модераторском. В первом человек видит только свои собственные статьи, во втором модератор видит все удаленные статьи.

@login_required
def deleted_articles(request, user_only):

  # Проверка прав
  profile = request.user.cicero_profile
  if not user_only and not profile.moderator:
    return HttpResponseForbidden('Нет прав просматривать все удаленные статьи')

  # Все удаленные статьи
  queryset = Article.deleted_objects.select_related()

  # Фильтрация статей для персонального режима
  if user_only:
    queryset = queryset.filter(author=profile)

  # Сборка параметров и отправка в generic view
  kwargs = {
    'queryset': queryset,
    'template_name': 'cicero/article_deleted_list.html',
    'extra_context': {
      'user_only': user_only and profile,
    },
  }
  kwargs.update(generic_info)
  return object_list(request, **kwargs)

Ну и соответственно с каждой удаленной статьей есть кнопочка "Восстановить", которая проделывает операцию обратную удалению.

Маленькое предостережение

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

print article.topic

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

Topic.objects.get(id=article.topic_id)

... который делается через менеджер по умолчанию, который не видит удаленные статьи и вернет 0 записей, если у топика стоит deleted не NULL.

У меня это вылезло при показе списка удаленных статей, где для каждой статьи в шаблоне показывается еще и топик, в котором она жила:

<td>{{ article.subject }}<td>{{ article.topic }}

И соответственно для тех статей, топики которых тоже были целиком удалены, запрос article.topic падал ровно по описанной выше причине. В моем случае, правда, обойти это получилось хитрым приемом: я просто дописал к запросу удаленных статей "select_related()" (см. код view выше). В результате топики вытягиваются не в момент article.topic, а жестко приjoin'иваются прямо в запросе статей, и по deleted не фильтруются.

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

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

  1. Murkt

    Ошибка в менеджере:

    class ArticleManager(models.Manager):
      def get_query_set(self):
        return super(ArticleManager, self).filter(deleted__isnull=True)
    

    Должно быть

    class ArticleManager(models.Manager):
      def get_query_set(self):
        return super(ArticleManager, self).get_query_set().filter(deleted__isnull=True)
    
  2. Иван Сагалаев

    Спасибо, поправил. На самом деле, менеджер статей вообще слегка не так выглядит (там нестандартный queryset создается), но в статье это несущественно, поэтому я его вручную написал прямо в редакторе :-)

  3. Alex Lebedev

    Есть у меня в программе место — проверка прочтённости — где таблички статей и топиков опрашиваются не через джанговский API, а напрямую SQL’ом, и соответственно никакие менеджеры на них не влияют. Туда пришлось слазить вручную и проставить ” and deleted is null” в пару мест. Причем, я пока не могу придумать способа, как таких ошибок принципиально избегать.

    Я бы хранил данные в таблице _articles, а всю основную работу осуществлял через вьюшку articles (... where deleted is null). Считаю, что хороший ORM должен предоставлять возможность прозрачно работать с вьюшками вместо таблиц. Как с этим в Django?

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

    Вьюха, в общем-то, на то и вьюха, что от Django не требуется ничего, чтобы ее понимать. Можно сказать модели, что она работает с "таблицей" с таким-то названием, и select'ы будут идти оттуда.

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

  5. Alex Lebedev

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

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

    Для Django есть DmMigration, который поможет значительно упростить управление миграциями уже существующей схемы.

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

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

  7. Dmitriy Dzema

    А почеу не сделать у модели статей метод delete, который будет ставить флажок об удалений? Зачем все время в коде пихать в поле deleted значение напрямую?

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

    Потому что у модели уже есть метод delete, который запись реально удаляет. И он мне еще понадобится, когда я буду удалять спам.

    Можно, да, сделать метод mark_deleted. Но сейчас этот код есть только в одном месте, поэтому что так, что так — пока примерно одинаково. Как только возникнет нужда еще раз, тут же отрефакторю :-)

  9. Александр

    Потому что у модели уже есть метод delete, который запись реально удаляет. И он мне еще понадобится, когда я буду удалять спам.

    Чем метод хуже менеджера? Его тоже переопределить:

    def delete(self):
        self.deleted = datetime.now()
        self.save()
        # ...
    def true_delete(this):
        super(self.__class__,self).delete()
    
  10. Иван Сагалаев

    Я так и написал. С той только разницей, что пара mark_deleted и delete нравится мне гораздо больше, чем delete и true_delete соответственно :-)

  11. Alexander Solovyov
    super(self.__class__,self).delete()
    

    Вообще не самая лучшая идея так делать. Привыкнешь писать, отнаследуешься от какого-то класса, у которого такая штука будет применена, и... и порадуешься, что ещё сказать. :)

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

    Интересно, как у разных людей мысли сходятся. Вот человек сделал практически один в один такую же штуку: http://nathanostgard.com/archives/2007/7/18/undelete-in-django/

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

  13. Ушелец

    Кстати. Вернее будет сделать Click me - ибо, кажется, некоторые браузеры иначе не будет сабмитить форму при нажатии. ИМХО. :)

  14. Alexey

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

  15. Alexey

    Мало того, force_update=True делу не поможет, потому что хитрая джанга включает в условие апдейта фильтры из умолчательного менеджера.

    И я теперь даже не знаю как редактировать такие вот "условно удалённые" объекты, которые зачем-то наворотил на каждом шагу /-:

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