company_banner

Как хранить сложную иерархию настроек в проектах 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.
    Southbridge 143,11
    Обеспечиваем стабильную работу серверов
    Поделиться публикацией
    Похожие публикации
    Комментарии 0

    Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

    Самое читаемое