В течении последних двух месяцев работал над плагином redmine_intouch для компании Centos-admin.ru.
После завершения работ решил поделиться некоторыми нюансами, с которыми пришлось столкнуться в процессе разработки.
В этой публикации расскажу о пути, который пришлось пройти для того, чтобы реализовать гибкую систему разнообразных настроек.
Перво-наперво хочу оговориться. Эта статья о реализации логики хранения настроек проекта в плагине для Redmine.
Т.к. это плагин, то использовать сторонние гемы, в которых данный функционал реализован — крайне нежелательно, во избежание конфликтов с логикой самого Redmine.
Поэтому в этой публикации речь будет идти о реализации с нуля системы хранения настроек со сложной иерархией.
Как видно из скриншота, нужно как-то хранить данные с трёх спойлеров, в каждом из которых по несколько вкладок, а на каждой вкладке масса чекбоксов.
Сперва я решил посмотреть, как подобный функционал реализован в других плагинах.
Просмотрев исходники плагинов используемых в компании, обнаружил похожий функционал в redmine_contacts. В нём есть модель ContactsSetting, которая позволяет сохранять специфические настройки с привязкой к проекту.
Как результат, в нашем плагине появилась такая моделька:
Хотя такой функционал работал, из-за него падала гибкость добавления новых настроек. Да и вообще такой код с первого взгляда не так уж просто понять.
По ходу реализации описанного выше функционала меня не покидала мысль, о том что такие настройки удобней всего хранить в хеше. Но до последнего я пытался не вносить изменений в таблицы Redmine. В этом случае нужно было добавить всего одно текстовое поле в таблицу projects.
Но всему есть предел. И желание удобней продолжать разработку плагина перевесило.
Я добавил поле intouch_settings в таблицу projects. Название с префиксом из имени плагина взял на случай, если в каком-то другом плагине добавляется поле settings к проекту.
И тут начались удобства. Понадобилось к патчу Project дописать
Позже в accessors добавилось ещё 3 поля. Удобно и наглядно!
А когда понадобилось добавить шаблоны настроек к плагину, такой способ хранения оказался очень удачным!
На помощь приходит метод try, наличествующий в рельсах.
Для примера приведу фрагмент кода, генерирующий таблицу отображённую на скриншоте в начале статьи:
Когда работы над плагином были завершены, в поле intouch_settings стала храниться подобная структура:
По реализации системы настроек можно было б ещё что-то написать, но, думаю, сказанного в публикации достаточно. Особо пытливым рекомендую в исходный код заглянуть. С кодом плагина можно ознакомиться в репозитории на GitHub.
После завершения работ решил поделиться некоторыми нюансами, с которыми пришлось столкнуться в процессе разработки.
В этой публикации расскажу о пути, который пришлось пройти для того, чтобы реализовать гибкую систему разнообразных настроек.
Перво-наперво хочу оговориться. Эта статья о реализации логики хранения настроек проекта в плагине для Redmine.
Т.к. это плагин, то использовать сторонние гемы, в которых данный функционал реализован — крайне нежелательно, во избежание конфликтов с логикой самого Redmine.
Поэтому в этой публикации речь будет идти о реализации с нуля системы хранения настроек со сложной иерархией.
Как видно из скриншота, нужно как-то хранить данные с трёх спойлеров, в каждом из которых по несколько вкладок, а на каждой вкладке масса чекбоксов.
Как же это всё хранить?
Сперва я решил посмотреть, как подобный функционал реализован в других плагинах.
Просмотрев исходники плагинов используемых в компании, обнаружил похожий функционал в 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.