📜 ⬆️ ⬇️

How to store a complex hierarchy of settings in Redmine projects

During the last two months he worked on the redmine_intouch plugin for the company Centos-admin.ru .

After completing the work, I decided to share some of the nuances that I had to face during the development process.

In this publication I will talk about the path that had to go in order to implement a flexible system of various settings.
')
First of all I want to make a reservation. This article is about implementing the logic of storing project settings in a plugin for Redmine.

Since this is a plugin, then using third-party gems in which this functionality is implemented is highly undesirable, in order to avoid conflicts with the logic of Redmine itself.

Therefore, in this publication we will talk about the implementation from scratch of the storage system settings with a complex hierarchy.

image

As you can see from the screenshot, you need to somehow store data from three spoilers, each with several tabs, and each tab has a mass of checkboxes.

How is all this stored?


At first I decided to see how this functionality is implemented in other plugins.

After reviewing the source of the plugins used in the company, I found a similar functionality in redmine_contacts . It has the ContactsSetting model, which allows you to save specific settings with reference to the project.

As a result, this model appeared in our plugin:

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 

Although this functionality worked, because of it, the flexibility of adding new settings fell. And in general, such code at first glance is not so easy to understand.

What are the alternatives?


In the course of implementing the functionality described above, the thought never left me that such settings are most conveniently stored in a hash. But until recently, I tried not to make changes to the Redmine tables. In this case, it was necessary to add just one text field to the projects table.

But there is a limit to everything. And the desire is more convenient to continue the development of the plug outweighed.

I added the intouch_settings field to the projects table. I took the name with the prefix from the name of the plugin in case in some other plugin adds the field settings to the project.

And here began the convenience. It took to patch the Project to add

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

Later in accessors 3 more fields were added. Convenient and clear!

And when you needed to add customization templates to the plugin, this storage method turned out to be very successful!

How now to bring into shape all this diversity?


The try method comes to the rescue.

For example, here’s a code snippet that generates the table displayed in the screenshot at the beginning of the article:

 <% 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 %> 

When the work on the plugin was completed, a similar structure was stored in the intouch_settings field:
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"]} 

And at the end


On the implementation of the settings system, it would be possible to write something else, but, I think, this is enough in the publication. Especially inquisitive I recommend to look into the source code. The plugin code can be found in the repository on GitHub .

Source: https://habr.com/ru/post/270615/


All Articles