10 October 2011

Написание плагина к Redmine

Website development
Про систему управления проектами Redmine наверное многие слышали, а некоторые возможно даже использовали в своей работе. Redmine — довольно гибкая кроссплатформенная система, написанная на известном фреймворке Ruby on Rails. Как и большинство подобных систем, Redmine позволяет расширять свою функциональность за счет сторонних плагинов. В данный момент уже имеется более тысячи таких плагинов на разный вкус и цвет. Я хочу рассказать об одном из них и о том как написать плагин к Redmine на его примере.

Repository plugin — плагин, позволяющий скачивать из репозитория (хранилища) проекта файлы и папки одним zip-архивом. Redmine предлагает наглядное руководство по созданию плагинов.
Итак, приступим к написанию плагина. Все, что написано ниже, касается redmine, установленного на веб сервер с ос Linux.
Для начала мы переходим в каталог, куда установлен redmine (например /var/www/redmine):

$ cd /var/www/redmine

Установим переменную среды RAILS_ENV:

$ export RAILS_ENV="production"

Теперь мы можем сгенерить необходимые для нашего плагина файлы:

$ ruby script/generate redmine_plugin repository
create vendor/plugins/redmine_repository/app/controllers
create vendor/plugins/redmine_repository/app/helpers
create vendor/plugins/redmine_repository/app/models
create vendor/plugins/redmine_repository/app/views
create vendor/plugins/redmine_repository/db/migrate
create vendor/plugins/redmine_repository/lib/tasks
create vendor/plugins/redmine_repository/assets/images
create vendor/plugins/redmine_repository/assets/javascripts
create vendor/plugins/redmine_repository/assets/stylesheets
create vendor/plugins/redmine_repository/lang
create vendor/plugins/redmine_repository/README
create vendor/plugins/redmine_repository/init.rb
create vendor/plugins/redmine_repository/lang/en.yml

Отредактируем файл vendor/plugins/redmine_repository/init.rb, указав
в нем имя, описание плагина, автора и минимальную версию redmine для которой написан плагин:
require 'redmine'
require 'dispatcher'

Redmine::Plugin.register :redmine_repository do
  name 'Redmine Repository plugin'
  author 'Sanny'
  description 'This is a reposirory plugin for Redmine'
  version '0.0.2'

  requires_redmine :version_or_higher => '1.1.2'
end

Наш плагин должен позволять выбирать какие файлы скачивать из хранилища. Поэтому в существующий вид хранилища надо добавить checkbox'ы
image
Т.к. надо изменить существующий вид хранилища, то самый простой способ — скопировать файлы, отвечающие за внешний вид хранилища в соответствующую директорию нашего плагина и затем отредактировать эти файлы:

$ cp app/views/repositories/_dir_lsit.rhtml vendor/plugins/redmine_repository/app/views/repositories
$ cp app/views/repositories/_dir_lsit_content.rhtml vendor/plugins/redmine_repository/app/views/repositories
$ cp app/views/repositories/show.rhtml vendor/plugins/redmine_repository/app/views/repositories

Файл _dir_lsit.rhtml отвечат за отображение дерева файловой системы хранилища. Туда мы добавим кнопку «Download» для загрузки:
<% if  authorize_for('repositories', 'entries_operation')  %>
<div style="float: right;">
        <%= submit_tag(l(:Download), :name => "download_entries") %>
</div>
<% end %>

Для того, чтобы кнопка заработала, обернем весь код в form_tag:
<% form_tag({:action => "entries_operation"}, :method => :post, :id => "Entries") do %>
...
<% if  authorize_for('repositories', 'entries_operation')  %>
<div style="float: right;">
        <%= submit_tag(l(:Download), :name => "download_entries") %>
</div>
<% end %>
<p>&nbsp</p>
<% end %>

Теперь добавим checkbox к каждой записи в отображении хранилища. Для этого в файл _dir_lsit_content.rhtml внесем следующие изменение:
вместо
<% if entry.is_dir? %>
<span class="expander" onclick="<%=  remote_function :url => {:action => 'show', :id => @project, :path => to_path_param(ent_path), :rev => @rev, :depth => (depth + 1), :parent_id => tr_id},
                  :method => :get,
                  :update => { :success => tr_id },
                  :position => :after,
                  :success => "scmEntryLoaded('#{tr_id}')",
                  :condition => "scmEntryClick('#{tr_id}')"%>">&nbsp</span>
<% end %>

напишем
<% if entry.is_dir? %>
<span class="expander" onclick="<%=  remote_function :url => {:action => 'show', :id => @project, :path => to_path_param(entry.path), :rev => @rev, :depth => (depth + 1), :parent_id => tr_id, :p
arent_val => entry.path},
                                                                        :method => :get,
                  :update => { :success => tr_id },
                  :position => :after,
                  :success => "scmEntryLoaded('#{tr_id}')",
                  :complete => "checkBranch('#{tr_id}', getParentNodeChecked('#{params[:parent_val]}'))",
                  :condition => "scmEntryClick('#{tr_id}')"%>">&nbsp</span>
<span><%= check_box_tag("folders[]", entry.path, false, :id => params[:parent_id], :onclick => "checkBranch('#{tr_id}', this.checked);" ) if  authorize_for('repositories', 'entries_operation')
%></span>
<% else %>
<span style="padding-left: 8px">&nbsp</span>
<span><%= check_box_tag("files[]", entry.path, false, :id => params[:parent_id]) if  authorize_for('repositories', 'entries_operation') %></span>
<% end %>

Здесь используются две самописные функции на javascript:
getParentNodeChecked(parentVal) — определяет состояние checkbox'а родителя данного узла дерева,
checkBranch(parentId, checked) — устанавливает checkbox'ы потомков узла дерева в состояние как у родителя (грубо говоря, если выделен каталог, то выделются все подкаталоги и файлы в этом каталоге).
Создадим их в новом файле assets/javascripts/repository.js
function getParentNodeChecked(parentVal)
{
  var form = document.getElementById('Entries');
  for (var i=0;i<form.elements.length;i++)
  {
    var e = form.elements[i];
    if(e.type=='checkbox' && e.value == parentVal)
        return e.checked;
  }
}
function checkBranch(parentId, checked)
{
  var allchecked = true;
  var has_check_tree = false;
  var form = document.getElementById('Entries');
  for (var i=0;i<form.elements.length;i++)
  {
    var e = form.elements[i];
    if(e.type=='checkbox')
    {
        if(e.id == parentId)
        {
          e.checked = !checked;
          e.click();
        }
        allchecked = allchecked && e.checked;
        if(e.name == "check_tree") has_check_tree = true;
    }
  }
  if(has_check_tree)
    form.check_tree.checked = allchecked;
}

Подключим наш repository.js к нашему новому отображению хранилища (в файле show.rhtml):
...
<% content_for :header_tags do %>
<%= stylesheet_link_tag "scm" %>
<%= javascript_include_tag "repository.js", :plugin => "redmine_repository" %>
<% end %>

Осталось добавить саму функцию архивированя выбранных папок и файлов. Реализация будет сделана на языке ruby. Дополнительно для поддержки zip архивов нужно поставить пакет rubyzip (gem install rubyzip).
Создадим класс RepositoryZip, который будет заниматься непосредственно архивированием (файл app/helpers/repository_zip.rb):
require 'zip/zip'
require 'zip/zipfilesystem'

class RepositoryZip
  attr_reader :file_count

  def initialize()
    @zip = Tempfile.new(["repository_zip",".zip"])
    @zip_file = Zip::ZipOutputStream.new(@zip.path)
    @file_count = 0
  end

  def finish
    @zip_file.close unless @zip_file.nil?
    @zip.path unless @zip.nil?
  end

  def close
    @zip_file.close unless @zip_file.nil?
    @zip.close unless @zip.nil?
  end

  def add_file(file, cat)
    @zip_file.put_next_entry(file)
    @zip_file.write(cat)
    @file_count += 1
  end

  def add_folder(folder)
    @zip_file.put_next_entry(folder + "/")
  end

end

Теперь «пропатчим» существующий в redmine класс RepositoriesController, добавив к нему несколько методов (файл lib/repositories_controller_patch.rb). Не буду подробно останавливаться на логике работы этого «патча», просто приведу код:
require 'tree'
require_dependency 'application_controller'
require_dependency 'repositories_controller'
require_dependency 'repository_zip'

module RepositoriesControllerPatch
  def self.included(base) # :nodoc:
    base.extend(ClassMethods)

    base.send(:include, InstanceMethods)

    base.class_eval do
      unloadable # послать unloadable чтобы не перегружать при разработке
    end

  end

  module ClassMethods
  end

  module InstanceMethods
         def entries_operation

            selected_folders = params[:folders].nil? ? [] : params[:folders]
            selected_files = params[:files].nil? ? [] : params[:files]

            if selected_folders.empty? && selected_files.empty?
              flash[:warning] = l(:warning_no_entries_selected)
              redirect_to :action => "show", :id => @project, :path => @path
              return
            end

            # make a selected files and folders tree
            selected_tree = Tree::TreeNode.new(".", "root")
            selected_files.each do |file|
                folder = Pathname.new(file).dirname.to_s
                selected_tree_node = selected_tree
                if !folder.match(/^\.+$/)
                    folder.split("/").each do
                       if selected_tree_node[folder].nil?
                          selected_tree_node =  selected_tree_node.add(Tree::TreeNode.new(folder, "folder"))
                       else
                          selected_tree_node = selected_tree_node[folder]
                       end
                    end
                    selected_tree_node << Tree::TreeNode.new(file, "file")
                else
                    selected_tree << Tree::TreeNode.new(file, "file")
                end
            end
            selected_folders.each do |folder|
                selected_tree_node = selected_tree
                folder.split("/").each do
                  if selected_tree_node[folder].nil?
                     selected_tree_node =  selected_tree_node.add(Tree::TreeNode.new(folder, "folder"))
                       else
                          selected_tree_node = selected_tree_node[folder]
                       end
                    end
                    selected_tree_node << Tree::TreeNode.new(file, "file")
                else
                    selected_tree << Tree::TreeNode.new(file, "file")
                end
            end
            selected_folders.each do |folder|
                selected_tree_node = selected_tree
                folder.split("/").each do
                  if selected_tree_node[folder].nil?
                     selected_tree_node =  selected_tree_node.add(Tree::TreeNode.new(folder, "folder"))
                  else
                     selected_tree_node = selected_tree_node[folder]
                  end
                end
            end

            begin
              if !params[:email_entries].blank?
                email_entries(selected_tree)
              else
                download_entries(selected_tree)
              end
            rescue => e
               flash[:warning] = l(:error_in_getting_files) +  " (" + e.message + ")"
               redirect_to :action => "show", :id => @project
            end

         end

         def download_entries(selected_tree)

            zip = RepositoryZip.new
            zip_entries(zip, selected_tree)

            send_file(zip.finish,
              :filename => filename_for_content_disposition(@project.name + "-" + DateTime.now.strftime("%y%m%d%H%M%S") + ".zip"),
              :type => "application/zip",
 :disposition => "attachment")
          ensure
            zip.close unless zip.nil?
          end

          def zip_entries(zip, selected_tree)
            selected_tree.children.each do |node|
                if node.content == "file"
                   zip.add_file(node.name, @repository.cat(node.name, @rev))
                else
                   zip.add_folder(node.name)
                   if node.hasChildren?
                     # add selected subfolders
                     zip_entries(zip, node)
                   else
                     # add all subfolders with files            
                     entries = @repository.entries(node.name, @rev)
                     entries.each do |entry|
                        node << Tree::TreeNode.new(entry.path, entry.is_dir? ? "folder" : "file")
                     end
                   end
                   zip_entries(zip, node)
                end
            end
            zip
          end
  end # of InstaceMethods
end # of module
RepositoriesController.send(:include, RepositoriesControllerPatch)


Метод entries_operation отвечает за формирования списка файлов и папок для архивации.
Метод download_entries отвечает за загрузку архива пользователем.
Метод zip_entries отвечает за архивирования выбранных файлоа и папок.

Вот вообщем-то и все. Остатется только добавить разрешения на загрузку файлов пользователям системы redmine и локализацию плагина. Редактируем файл init.rb для добавления разрешений:
require 'redmine'
require 'dispatcher'
require 'repositories_controller_patch'

Redmine::Plugin.register :redmine_repository do
  name 'Redmine Repository plugin'
  author 'Sanny'
  description 'This is a reposirory plugin for Redmine'
  version '0.0.2'

  requires_redmine :version_or_higher => '1.1.2'
end

Redmine::AccessControl.map do |map|
        map.project_module :repository do |map|
                map.permission :operations, :repositories => [:entries_operation]
        end
end

Файлы локализации находятся в каталоге config/locales. Для русского языка создаем файл ru.yml:
ru:
  Download: "Download"
  warning_no_entries_selected: "Ничего не выбрано"
  error_in_getting_files: "Ошибка при доступе к файлам"
  permission_operations: "Групповая загрузка файлов одним архивом"

Перезапускаем redmine, настраиваем разрешения на скачивания файлов одним архивом для различных пользователей системы и пользуемся.
Tags:система управления проектамиredmine
Hubs: Website development
+38
15.7k 98
Comments 16