Как хранить сложную иерархию настроек в проектах Redmine

от автора

В течении последних двух месяцев работал над плагином 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.

ссылка на оригинал статьи http://habrahabr.ru/post/270615/


Комментарии

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

Ваш адрес email не будет опубликован. Обязательные поля помечены *