В течении последних двух месяцев работал над плагином redmine_intouch для компании Centos-admin.ru.

После завершения работ решил поделиться некоторыми нюансами, с которыми пришлось столкнуться в процессе разработки.

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

Перво-наперво хочу оговориться. Эта статья о реализации логики хранения настроек проекта в плагине для Redmine.

Т.к. это плагин, то использовать сторонние гемы, в которых данный функционал реализован — крайне нежелательно, во избежание конфликтов с логикой самого Redmine.

Поэтому в этой публикации речь будет идти о реализации с нуля системы хранения настроек со сложной иерархией.

image

Как видно из скриншота, нужно как-то хранить данные с трёх спойлеров, в каждом из которых по несколько вкладок, а на каждой вкладке масса чекбоксов.

Как же это всё хранить?


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

Просмотрев исходники плагинов используемых в компании, обнаружил похожий функционал в redmine_contacts. В нём есть модель ContactsSetting, которая позволяет сохранять специфические настройки с привязкой к проекту.

Как результат, в нашем плагине появилась такая моделька:

intouch_setting.rb
class IntouchSetting < ActiveRecord::Base
  unloadable
  belongs_to :project

  attr_accessible :name, :value, :project_id

  cattr_accessor :available_settings
  self.available_settings ||= {}

  def self.load_available_settings
    %w(alarm new working feedback overdue).each do |notice|
      %w(author assigned_to watchers).each do |receiver|
        define_setting "telegram_#{notice}_#{receiver}"
      end
      define_setting "telegram_#{notice}_telegram_groups", serialized: true, default: {}
      define_setting "telegram_#{notice}_user_groups", serialized: true, default: {}
    end
    define_setting 'email_cc', default: ''
  end


  def self.define_setting(name, options={})
    available_settings[name.to_s] = options
  end
  # Hash used to cache setting values
  @intouch_cached_settings = {}
  @intouch_cached_cleared_on = Time.now

  # Hash used to cache setting values
  @cached_settings = {}
  @cached_cleared_on = Time.now


  validates_uniqueness_of :name, scope: [:project_id]

  def value
    v = read_attribute(:value)
    # Unserialize serialized settings
    if available_settings[name][:serialized] && v.is_a?(String)
      v = YAML::load(v)
      v = force_utf8_strings(v)
    end
    # v = v.to_sym if available_settings[name]['format'] == 'symbol' && !v.blank?
    v
  end

  def value=(v)
    v = v.to_yaml if v && available_settings[name] && available_settings[name][:serialized]
    write_attribute(:value, v.to_s)
  end

  # Returns the value of the setting named name
  def self.[](name, project_id)
    project_id = project_id.id if project_id.is_a?(Project)
    v = @intouch_cached_settings[hk(name, project_id)]
    v ? v : (@intouch_cached_settings[hk(name, project_id)] = find_or_default(name, project_id).value)
  end

  def self.[]=(name, project_id, v)
    project_id = project_id.id if project_id.is_a?(Project)
    setting = find_or_default(name, project_id)
    setting.value = (v ? v : "")
    @intouch_cached_settings[hk(name, project_id)] = nil
    setting.save
    setting.value
  end

  # Checks if settings have changed since the values were read
  # and clears the cache hash if it's the case
  # Called once per request
  def self.check_cache
    settings_updated_on = IntouchSetting.maximum(:updated_on)
    if settings_updated_on && @intouch_cached_cleared_on <= settings_updated_on
      clear_cache
    end
  end

  # Clears the settings cache
  def self.clear_cache
    @intouch_cached_settings.clear
    @intouch_cached_cleared_on = Time.now
    logger.info "Intouch settings cache cleared." if logger
  end

  load_available_settings


  private

  def self.hk(name, project_id)
    "#{name}-#{project_id.to_s}"
  end

  def self.find_or_default(name, project_id)
    name = name.to_s
    raise "There's no setting named #{name}" unless available_settings.has_key?(name)
    setting = find_by_name_and_project_id(name, project_id)
    unless setting
      setting = new(name: name, project_id: project_id)
      setting.value = available_settings[name][:default]
    end
    setting
  end

  def force_utf8_strings(arg)
    if arg.is_a?(String)
      arg.dup.force_encoding('UTF-8')
    elsif arg.is_a?(Array)
      arg.map do |a|
        force_utf8_strings(a)
      end
    elsif arg.is_a?(Hash)
      arg = arg.dup
      arg.each do |k,v|
        arg[k] = force_utf8_strings(v)
      end
      arg
    else
      arg
    end
  end
end

Хотя такой функционал работал, из-за него падала гибкость добавления новых настроек. Да и вообще такой код с первого взгляда не так уж просто понять.

Какие есть альтернативы?


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

Но всему есть предел. И желание удобней продолжать разработку плагина перевесило.

Я добавил поле intouch_settings в таблицу projects. Название с префиксом из имени плагина взял на случай, если в каком-то другом плагине добавляется поле settings к проекту.

И тут начались удобства. Понадобилось к патчу Project дописать

store :intouch_settings,  accessors: %w(telegram_settings email_settings)

Позже в accessors добавилось ещё 3 поля. Удобно и наглядно!

А когда понадобилось добавить шаблоны настроек к плагину, такой способ хранения оказался очень удачным!

Как же теперь выводить в форму всё это разнообразие?


На помощь приходит метод try, наличествующий в рельсах.

Для примера приведу фрагмент кода, генерирующий таблицу отображённую на скриншоте в начале статьи:

<% IssueStatus.order(:position).each do |status| %>
    <tr>
      <th>
        <%= status.name %>
      </th>
      <% IssuePriority.order(:position).each do |priority| %>
        <td>
          <% Intouch.active_protocols.each do |protocol| %>
            <%= check_box_tag "intouch_settings[#{protocol}_settings][author][#{status.id}][]", priority.id,
                              @project.send("#{protocol}_settings").try(:[], 'author').
                                  try(:[], status.id.to_s).try(:include?, priority.id.to_s) %>
            <%= label_tag l "intouch.protocols.#{protocol}" %><br>
          <% end %>
        </td>
      <% end %>
    </tr>
  <% end %>

Когда работы над плагином были завершены, в поле intouch_settings стала храниться подобная структура:
intouch_settings
{"settings_template_id"=>"2",
   "telegram_settings"=>
    {"author"=>{"1"=>["2", "5"], "2"=>["2", "5"], "3"=>["5"], "5"=>["1", "2", "3", "4", "5"]},
     "assigned_to"=>
      {"1"=>["1", "2", "3", "4", "5"],
       "2"=>["1", "2", "3", "4", "5"],
       "3"=>["1", "2", "3", "4", "5"],
       "4"=>["1", "2", "3", "4", "5"],
       "5"=>["1", "2", "3", "4", "5"],
       "6"=>["1", "2", "3", "4", "5"]},
     "watchers"=>{"1"=>["5"], "2"=>["5"], "3"=>["5"], "5"=>["1", "2", "3", "4", "5"]},
     "groups"=>
      {"1"=>{"1"=>["2", "5"], "2"=>["2", "5"], "3"=>["5"], "5"=>["1", "2", "3", "4", "5"]},
       "2"=>
        {"1"=>["1", "2", "3", "4", "5"],
         "2"=>["1", "2", "3", "4", "5"],
         "3"=>["1", "2", "3", "4", "5"],
         "4"=>["1", "2", "3", "4", "5"],
         "5"=>["1", "2", "3", "4", "5"],
         "6"=>["1", "2", "3", "4", "5"]}},
     "working"=>{"author"=>"1", "assigned_to"=>"1", "watchers"=>"1", "groups"=>["1"]},
     "feedback"=>{"author"=>"1", "assigned_to"=>"1", "watchers"=>"1", "groups"=>["1"]},
     "unassigned"=>{"author"=>"1", "watchers"=>"1", "groups"=>["1"]},
     "overdue"=>{"author"=>"1", "assigned_to"=>"1", "watchers"=>"1", "groups"=>["1", "2"]}},
   "reminder_settings"=>
    {"1"=>{"active"=>"1", "interval"=>"1"},
     "2"=>{"active"=>"1", "interval"=>"1"},
     "3"=>{"active"=>"1", "interval"=>"1"},
     "4"=>{"active"=>"1", "interval"=>"1"},
     "5"=>{"active"=>"1", "interval"=>"1"}},
   "email_settings"=>
    {"unassigned"=>{"user_groups"=>["5", "9"]},
     "overdue"=>{"assigned_to"=>"1", "watchers"=>"1", "user_groups"=>["5", "9"]}},
   "assigner_groups"=>["5", "9"]}

И в завершение


По реализации системы настроек можно было б ещё что-то написать, но, думаю, сказанного в публикации достаточно. Особо пытливым рекомендую в исходный код заглянуть. С кодом плагина можно ознакомиться в репозитории на GitHub.

Комментарии (0)