Представьте себе такую ситуацию: вы открываете страницу администратора вашего сайта, чтобы провести некоторую очистку, находите старые данные, которые никто не просматривал целую вечность, и удаляете их. Удаление успешно, и все в порядке … но через секунду … «НОООО !!! Эти данные содержали ОЧЕНЬ ВАЖНУЮ ИНФОРМАЦИЮ, которая могла бы изменить мир! », Понимаете вы. Но это ушло, и мир остался неизменным (ну, есть шанс на восстановление, если у вас есть резервная копия БД :)).
Можем ли мы предотвратить эту ситуацию в нашем приложении Rails? Да мы можем! paper_trail
на помощь!
В этой статье мы поговорим о том, как реализовать страницу «История» и кнопку «отменить» (а также «повторить») с помощью paper_trail
.
Исходный код доступен на GitHub .
Рабочая демоверсия доступна по адресу http://undoer.radiant-wind.com/ .
Подготовка демо-проекта
Для этой демонстрации я собираюсь использовать Rails 4.0.4, но такое же решение можно реализовать с помощью Rails 3.
Мы собираемся создать простой блог, который позволит зарегистрированным пользователям добавлять, обновлять и удалять сообщения. Пользователь сможет отменить все действия, связанные с сообщениями (например, отмена непреднамеренного удаления). Мы также предоставим страницу «История», отображающую список действий (и некоторую другую информацию), которые пользователи выполняли при работе с сообщениями.
Хорошо, время начинать. Создайте новое приложение Rails без набора тестов по умолчанию, запустив:
$ rails new undoer -T
Вот список драгоценных камней, которые мы собираемся использовать:
Gemfile
gem 'paper_trail', '~> 3.0.1' gem 'bootstrap-sass', '~> 3.1.1.0' gem 'devise'
bootstrap-sass
включает в себя Twitter Bootstrap, нашего старого друга, который поможет нам стилизовать проект, а devise
будет использоваться для настройки некоторой очень простой аутентификации.
paper_trail
, созданный Энди Стюартом, является главной звездой этой статьи — он поможет нам создать страницу «История» и кнопку «отменить». Есть некоторые другие гемы, которые обеспечивают версионность, но я считаю paper_trail
наиболее удобным. Раньше я пользовался аудированной Коллективной идеей, но она не так полезна и, более того, не обновлялась 8 месяцев. Также он имеет сомнительную совместимость с Rails 4.
Демо-приложение имеет один контроллер (помимо ApplicationController
), PostsController
, который будет использоваться для управления нашими сообщениями. Изначально у него есть семь методов по умолчанию: index
, create
new
, create
, destroy
, edit
и update
, а также ряд связанных с ними представлений. Я не буду вдаваться в подробности о том, как создавать эти методы и представления, потому что они действительно простые (для краткости вы можете создать их, используя rails g scaffold Posts
).
Демо также содержит две модели: Post
и User
. Таблица posts
имеет следующие столбцы:
-
id
-
title
-
body
-
created_at
-
updated_at
Модель User
была сгенерирована Devise и содержит электронную почту вместе с некоторыми другими специальными полями. Опять же, я не буду вдаваться в подробности настройки Devise, потому что это не связано с темой статьи. Для начала вы можете использовать следующее руководство: https://github.com/plataformatec/devise#getting-started . Кроме того, не стесняйтесь любой подход аутентификации.
Файл маршрутов выглядит так:
routes.rb
devise_for :users root to: 'posts#index' resources :posts
На данный момент мы готовы к установке paper_trail
. Прежде всего, создайте специальную таблицу для хранения версий (это крутая особенность paper_trail
— если мы хотим очистить старые версии позже, нам потребуется только доступ к одной таблице).
Запустите эти команды:
$ bundle exec rails generate paper_trail:install $ bundle exec rake db:migrate
Это создаст и применит необходимую миграцию. Теперь добавьте следующую строку в вашу модель Post
:
модели / post.rb
class Post < ActiveRecord::Base has_paper_trail end
И это все! Теперь все изменения в таблице posts
будут проверяться автоматически. Как это круто?
Вы также можете указать, какие изменения не должны отслеживаться. Например, если в posts
был столбец view_count
и мы не хотели отслеживать внесенные в него изменения, изменение выглядит так:
модели / post.rb
has_paper_trail ignore: [:view_count]
Некоторые поля могут быть пропущены полностью, то есть они не будут ни отслеживаться, ни включаться в сериализованную версию объекта):
модели / post.rb
has_paper_trail skip: [:view_count]
Также можно указать события:
модели / post.rb
has_paper_trail on: [:update, :create]
или укажите условия для отслеживания события:
модели / post.rb
has_paper_trail if: Proc.new { |t| t.title.length > 10 }, unless: Proc.new { |t| t.body.blank? }
Простой, но мощный.
Отображение журнала
Теперь вы, вероятно, хотите отобразить проверенные версии, а? Это просто, просто добавьте следующий маршрут:
routes.rb
[...] get '/posts/history', to: 'posts#history', as: :posts_history resources :posts
на страницу истории для сообщений. Если в вашем приложении проверено много моделей, рекомендуется создать отдельный VersionsController
и поместить в него все методы, связанные с версиями. Однако в нашем случае проверяется только модель Post
, поэтому давайте остановимся на одном контроллере.
Добавьте новый метод в контроллер:
Контроллеры / posts_controller.rb
def history @versions = PaperTrail::Version.order('created_at DESC') end
Обратите внимание, что мы должны использовать PaperTrail::Version
, а не только Version
. Эта строка кода извлекает все записанные события из таблицы versions
которую мы создали ранее, и сортирует их по дате создания. В реальном приложении рекомендуется kaminari
эти события на страницы с помощью will_paginate
или kaminari
gem.
Визуализация событий:
сообщений / history.html.erb
<div class="container"> <h1>History</h1> <ul> <% @versions.each do |version| %> <li> <%= l(version.created_at, format: "%-d.%m.%Y %H:%M:%S %Z") %><br/> Event ID: <%= version.id %><br/> <b>Target:</b> <%= version.item_type %> <small>(id: <%= version.item_id %>)</small>; <b>action</b> <%= version.event %>;<br/> <div> More info: <pre><%= version.object %></pre> </div> </li> <% end %> </ul> </div>
Вот данные, которые отображаются:
-
version.created_at
— когда произошло это событие. -
version.id
— идентификатор этого события. -
version.item_type
— Название модели для события. В нашем случае этоPost
. -
version.item_id
— идентификатор ресурса (сообщения), который был изменен. -
version.event
— действие, примененное к ресурсу (создание, обновление, уничтожение). -
version.object
— Полный дамп ресурса, который был изменен.
Все идет нормально. Тем не менее, есть некоторые вещи, которые можно улучшить. Например, какие поля были изменены (особенно для действия обновления)? Ну, это очень легко реализовать.
Создайте и примените следующую миграцию:
$ rails g migration add_object_changes_to_versions object_changes:text $ rake db:migrate
Это также можно сделать при настройке paper_trail
. Вам просто нужно предоставить соответствующую опцию для генератора:
$ rails generate paper_trail:install --with-changes
Никаких дальнейших действий не требуется, paper_trail
автоматически paper_trail
версии.
Теперь мы можем добавить новый блок div
в наше представление:
сообщений / history.html.erb
[...] <div> Changeset: <pre><%= version.changeset %></pre> </div> [...]
набор[...] <div> Changeset: <pre><%= version.changeset %></pre> </div> [...]
Это отобразит значения атрибута до и после события (если атрибут остался неизменным, он не будет отображаться).
Отслеживание пользовательской информации
Хорошо, теперь мы знаем, когда мы удалили наш драгоценный пост в блоге. Но мы не знаем, кто ! Самое время исправить эту проблему.
Давайте отследим IP-адрес пользователя, ответственного за действие. Конечно, IP-адрес может быть подделан, но главное здесь — объяснить, как хранить метаданные вместе с данными события. Перейдите и создайте новую миграцию:
$ rails g migration add_ip_to_versions ip:string $ rake db:migrate
paper_trail
по умолчанию ничего не будет хранить в столбце ip
, поэтому нам нужно немного помочь. Добавьте этот метод в ApplicationController
:
Контроллеры / application_controller.rb
def info_for_paper_trail # Save additional info { ip: request.remote_ip } end
paper_trail
будет использовать этот метод для получения дополнительной информации и сохранения ее в качестве метаданных. Если вы используете Rails 3 или protected_attributes
с Rails 4, вам также необходимо создать инициализатор:
Инициализаторы / paper_trail.rb
module PaperTrail class Version < ActiveRecord::Base attr_accessible :ip end end
Метаданные также могут быть предоставлены в модели следующим образом (при условии, что у нас есть столбец timestamp
):
модели / post.rb
has_paper_trail meta: { timestamp: Time.now }
Последнее, что нужно сделать, это добавить строку в представление:
сообщений / history.html.erb
[...] <b>Remote address:</b> <%= version.ip %><br/> [...]
Хранение IP — это хорошо, но помните, что у нас настроена базовая система аутентификации — не могли бы мы также сохранить пользователя, ответственного за действие? Это было бы очень удобно! Конечно, мы можем сделать это также.
На этот раз нам не нужно применять миграцию к таблице versions
, поскольку она уже содержит столбец whodunnit
который используется для хранения информации о пользователе. Все, что нам нужно сделать, это создать еще один метод в ApplicationController
:
Контроллеры / application_controller.rb
[...] def user_for_paper_trail # Save the user responsible for the action user_signed_in? ? current_user.id : 'Guest' end [...]
Пользователь user_signed_in?
Метод предоставляется Devise — в основном, он сообщает, вошел ли пользователь в систему или нет. current_user
определяется Devise и возвращает пользователя, вошедшего в данный момент в качестве ресурса (если пользователь не вошел в систему, он возвращает nil
.) Таким образом, мы можем легко получить id
текущего пользователя. Пока что только пользователи, прошедшие проверку, могут управлять публикациями в нашем блоге, но это может измениться. В случае отсутствия регистрации мы указываем «Гость».
Последнее, что нужно сделать, это отобразить эту новую информацию:
сообщений / history.html.erb
[...] <b>User:</b> <% if version.whodunnit && version.whodunnit != 'Guest' %> <% user = User.find_by_id(version.whodunnit) %> <% if user %> <%= user.email %> (last seen at <%= l(user.last_sign_in_at, format: "%-d.%m.%Y %H:%M:%S") %>) <% end %> <% else %> Guest <% end %> [...]
На нашей странице «История» теперь представлена действительно полезная информация. Мы можем отслеживать, когда произошло событие, что было изменено, как выглядел ресурс до изменения и кто несет ответственность за это изменение. Потрясающие!
Отмена действия
Давайте перейдем ко второй части, позволяющей нашим пользователям отменять свои действия.
Для этого создайте новый метод, который отменит запрошенное действие:
Контроллеры / posts_controller.rb
def undo @post_version = PaperTrail::Version.find_by_id(params[:id]) begin if @post_version.reify @post_version.reify.save else # For undoing the create action @post_version.item.destroy end flash[:success] = "Undid that!" rescue flash[:alert] = "Failed undoing the action..." ensure redirect_to root_path end end
Выше найдите версию по идентификатору (мы сгенерируем соответствующую ссылку позже). Затем проверьте, есть ли предыдущие версии, доступные для ресурса, используя метод reify
. Этот метод вернет nil
если ресурс был только что создан в текущей версии (очевидно, если ресурс был только что создан, у него нет предыдущих версий). Либо откат к предыдущей версии с использованием @post_version.reify.save
либо @post_version.reify.save
нового созданный ресурс с использованием @post_version.item.destroy
( @post_version.item
возвращает фактический ресурс). Просто, не правда ли?
Конечно, нам нужно добавить новый маршрут:
routes.rb
[...] post '/posts/:id/undo', to: 'posts#undo', as: :undo resources :posts [...]
Пользователю должна быть предоставлена ссылка для отмены его действия после того, как он что-то сделал с сообщением. Самый простой способ — поместить эту ссылку во флэш-сообщение, поэтому убедитесь, что она отображается в вашем макете:
макеты / application.html.erb
<div class="container"> <% flash.each do |key, value| %> <div class="alert alert-<%= key %>"> <button type="button" class="close" data-dismiss="alert">×</button> <%= value.html_safe %> </div> <% end %> </div>
Обратите внимание на использование html_safe
— это необходимо, потому что в противном случае наша ссылка будет отображаться как необработанный текст.
Создайте приватный метод в PostsController
который генерирует ссылку отмены:
Контроллеры / posts_controller.rb
[...] private def make_undo_link view_context.link_to 'Undo that plz!', undo_path(@post.versions.last), method: :post end
Мы не можем использовать link_to
внутри контроллера, поэтому ссылка на view_context
указывает на фактическое представление. @post.versions
извлекает все версии для ресурса Post
а @post.versions.last
самую последнюю. Этот метод можно использовать так:
Контроллеры / posts_controller.rb
[...] def update @post = Post.find_by_id(params[:id]) if @post.update_attributes(post_params) flash[:success] = "Post was updated! #{make_undo_link}" redirect_to post_path(@post) else render 'edit' end end [...]
Обязательно добавьте его в методы create
и destroy
.
Попробуйте и попробуйте в демонстрационном приложении. Обратите внимание, что отмена действия также отслеживается paper_trail
.
Повторить действие
Хорошо, я отменил действие … но теперь я хочу повторить его. Взрыва. Здесь мы должны ввести ссылку возврата, которая отменяет отмену. Есть только несколько модификаций, необходимых.
Создайте еще один приватный метод:
Контроллеры / posts_controller.rb
[...] private def make_redo_link params[:redo] == "true" ? link = "Undo that plz!" : link = "Redo that plz!" view_context.link_to link, undo_path(@post_version.next, redo: !params[:redo]), method: :post end [...]
Этот метод очень похож на make_undo_link
. Основным отличием является params[:redo]
который является либо true
либо false
. Исходя из этого параметра, измените текст ссылки — URL фактически остается неизменным. Это потому, что повторение в основном означает возврат к предыдущей версии, которая абсолютно совпадает с действием отмены.
Измените флэш-сообщение в методе undo
:
Контроллеры / posts_controller.rb
[...] def undo [...] flash[:success] = "Undid that! #{make_redo_link}" [...]
Это все! Пользователи могут отменять и повторять свои действия столько раз, сколько захотят, каждый раз, когда они записываются paper_trail
.
Единственный недостаток в том, что таблица versions
может очень быстро набрать вес. Вероятно, это должно быть выполнено с помощью некоторого фонового процесса для удаления старых записей. Работа будет использовать что-то вроде:
PaperTrail::Version.delete_all ["created_at < ?", 1.week.ago]
Вы также можете ограничить количество созданных версий для объекта, поместив эту строку в инициализатор:
PaperTrail.config.version_limit = 3
Вывод
Это подводит нас к концу статьи. Имейте в виду, что с paper_trail
вы можете сделать гораздо больше, чем мы обсуждали. Например, вы можете выбрать версии в определенный момент времени ( @post.version_at(1.day.ago)
) или @post.version_at(1.day.ago)
между версиями ( @post.previous_version
, @post.next_version
) или работать с ассоциациями моделей. Вы даже можете создать систему, которая использует две произвольные версии ресурса (аналогично системе сравнения, которая реализована на вики-сайтах).
Я надеюсь, что эта статья была полезна и интересна для вас. Если нет, я могу переделать это (стон)…