В течении последних двух месяцев работал над плагином redmine_intouch для компании Centos-admin.ru.
После завершения работ, решил поделиться некоторыми нюансами, с которыми пришлось столкнуться в процессе разработки.
В этой публикации расскажу о пути, который пришлось пройти для того, чтобы реализовать гибкую систему разнообразных настроек.
Перво-наперво хочу оговориться. Эта статья о реализации логики хранения настроек проекта в плагине для Redmine.
Т.к. это плагин, то использовать сторонние гемы, в которых данный функционал реализован — крайне нежелательно, во избежание конфликтов с логикой самого Redmine.
Поэтому в этой публикации речь будет идти о реализации с нуля системы хранения настроек со сложной иерархией.
Как видно из скриншота, нужно как-то хранить данные с трёх спойлеров, в каждом из которых по несколько вкладок, а на каждой вкладке масса чекбоксов.
Как же это всё хранить?
Сперва я решил посмотреть, как подобный функционал реализован в других плагинах.
Просмотрев исходники плагинов используемых в компании, обнаружил похожий функционал в redmine_contacts. В нём есть модель ContactsSetting, которая позволяет сохранять специфические настройки с привязкой к проекту.
Как результат, в нашем плагине появилась такая моделька:
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 стала храниться подобная структура:
{"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/
Добавить комментарий