Plugin Architecture Help

Added by Pascal Schoenhardt over 7 years ago

Hi Guys,

I'm new to Rails; I've just read a Mr. Neighborly's Humble Little Ruby Book, and some of the Rails guides on guids.rubyonrails.org. I've got a rough idea of how to achieve what I need, but I'm stuck on some specifics.

Goal
I am hoping to create a plugin that will allow me to add two fields to an issue; they will be drop down lists, and their contents will be the @issue.assignable_versions list. The purpose of these is to two fields is to track "Found in Version" (where was the bug found?) and "Release Notes Version" (which release notes collection should the Release Notes [custom field] contents be added to? Release notes are collected by an external script.).

What I've Got
So far, I have created a plugin, and within that plugin I have generated a Model called version_field using the following command:

ruby script/generate redmine_plugin_model VersionFields version_field name:string version:references issue:references

This didn't quite create the migration script I wanted, so I modified the migration script to contain:
class CreateVersionFields < ActiveRecord::Migration
  def self.up
    create_table :version_fields do |t|
      t.string :name
      t.references :version
      t.references :issue
    end
  end

  def self.down
    drop_table :version_fields
  end
end

The version_field Model file contains:
class VersionField < ActiveRecord::Base
  belongs_to :issue
  belongs_to :version
end

The Problem
I'm not sure how to put this into the MVC paradigm. I need to insert these fields into the Edit, New, and Show views for the Issue. I can create some partials to do that, but what view do they belong to? This plugin doesn't really need a view of its own. I'm also not quite clear on how to get the Issue view to show my stuff...

I know I can monkey patch/"duck punch" (I love that name) the Issue model to add the "has_many :version_field" association, allowing me to grab the version fields via "@issue.version_fields". However, I don't really know where my markup should live... logically I should somehow extend the Issue view; is that possible? As to saving changes, I suppose I could add a method that is called by the "after_save" event, which does something like:

@issue.version_fields.each do |version_field|
  version_field.update_attributes(???Where do I get the attributes from???) 
end

However, I don't really know where to get the attributes from. I guess it depends on how I insert the markup into the page...

Any help you can give me here would be greatly appreciated.

Thanks!

Replies (9)

RE: Plugin Architecture Help - Added by Felix Schäfer over 7 years ago

Reading what you are trying to achieve, my first question would be: Why not go with the custom fields you can define for a ticket? (nevermind, reread the thing and now get it…)

Other than that: I think you'd have better luck adding these 2 fields as attributes of the model rather than creating new models for them altogether. Anyway, you will need to monkey patch the issue model or controller either way (which is how rails and more specifically ruby works, nothing evil with this, just use it with caution): If you go with an extra model with a one-to-many relation, you'll need to "inform" the issue model about it, if you add the fields as extra attributes, you'll maybe need some validation for them.

Regarding the views: if the view hooks (of which there are only few) aren't enough, you'll need to copy the corresponding views over in your plugin (same relative path than in the redmine app) and modify them there. The rails engines (that's what redmine plugins are) views work so that when you request a page, it is first looked up in SOME_RAILS_APP/vendor/plugins/some_rails_engine/app/views then in SOME_RAILS_APP/app/views if it couldn't find it in any engine/plugin.

A short note on why it should be an attribute of the issue model rather than an extra model: you basically have a one-to-many relation between issues and versions (an issue can have one "found in" and one "release note" version), so I think you'd be better off extending (or monkey-patching if you will ;-) ) the issue model with has_one :version, :as => "found_in_version", … and has_one :version, :as => "release_note_version", … (not sure of the syntax, please look it up in the ActiveRecord API). You'll obviously also need 2 more columns in the issues's migration (something like found_in_version_id:integer and release_note_version_id:integer), but I hope you get the idea.

If anything is unclear, I can try to help you a little further along, but be warned that my free time is scarce and far between, so it might take some time for me to answer questions you'd have. You could also ask Eric Davis (edavis10 on IRC, #redmine on freenode), he is somewhat more knowledgeable and more experienced in redmine plugins matters.

RE: Plugin Architecture Help - Added by Pascal Schoenhardt over 7 years ago

Hi Felix.

Thanks for the help, and thanks to Eric, too.

I've nearly got this thing doing what I want. When I'm done, I'll post the full details here. I'm sure its bound to help someone else at some point.

RE: Plugin Architecture Help - Added by Felix Schäfer over 7 years ago

Glad we could help (though I didn't see the discussion with Eric, but I imagine he was able to help you out :-) ).

RE: Plugin Architecture Help - Added by John Fisher over 7 years ago

I need something very like this. In my case I need to use a pre-set list of versions, and stamp each bug "found-in" and when the bug is fixed, "fixed-in".

Any help, sharing, or whatever would be much appreciated Pascal.

John

RE: Plugin Architecture Help - Added by Pascal Schoenhardt over 7 years ago

I will post with the details of what I did tomorrow from work.

RE: Plugin Architecture Help - Added by Pascal Schoenhardt over 7 years ago

OK - here's the documentation I just wrote for my employer's internal Wiki. I hope this helps you, John.

Overview

Redmine supports custom fields, but this functionality wasn't enough to support two fields we required: "Found In" and "Target Release Notes". This is because those two fields need to reference the list of releases from Redmine's native "Target Version" field, and the custom fields functionality doesn't allow you to reference existing data sets. Since it would have been unfeasible to manually maintain a two separate copies of the release list for every project, a plugin was written to add those two fields into Redmine directly, referencing the existing data.

Our plugin is in 'vendor/plugins/redmine_version_fields'; in the init.rb file, you will find this code fragment:

  unless Issue.included_modules.include? RedmineVersionFields::IssuePatch
    Issue.send(:include, RedmineVersionFields::IssuePatch)
  end
  unless Version.included_modules.include? RedmineVersionFields::VersionPatch
    Version.send(:include, RedmineVersionFields::VersionPatch)
  end
  unless Query.included_modules.include? RedmineVersionFields::QueryPatch
    Query.send(:include, RedmineVersionFields::QueryPatch)
  end

This uses the Ruby on Rails core dispatcher to send an "include" message to the Issue, Version, and Query models; this is a request for them to include the IssuePatch , VersionPatch , and QueryPatch modules, respectively. These patches are defined under the plugin's root at 'lib/redmine_version_fields/X_patch.rb'

Patch file syntax

This is the shell of the issue_patch.rb file, with some of the code removed to keep things concise. This section is only meant to examine the overall structureof this "duck punch". Once this is understood, it should be easy to understand the removed code.

module RedmineVersionFields
  module IssuePatch

    def self.included(base) # :nodoc:
      base.extend(ClassMethods)
      base.send(:include, InstanceMethods)

      # Wrap the methods we are extending
      base.alias_method_chain :validate, :wrapping
      base.alias_method_chain :move_to,  :wrapping

      # Exectue this code at the class level (not instance level)
      base.class_eval do
        unloadable # Send unloadable so it will not be unloaded in development

        belongs_to :found_in_version, :class_name => 'Version', :foreign_key => 'found_in_version_id'
        belongs_to :release_notes_version, :class_name => 'Version', :foreign_key => 'release_notes_version_id'
      end #base.class_eval

    end #self.included

    module ClassMethods
    end

    module InstanceMethods

      # Wrapped validator - calls the original validator then does extra checks
      def validate_with_wrapping
        validate_without_wrapping

        # Removed custom validation code
      end #validate_with_wrapping

      # Wrapped move_to - calls the original mover, then ensures the versions are 
      # still valid.
      def move_to_with_wrapping (new_project, new_tracker = nil, options = {})
        result = move_to_without_wrapping(new_project, new_tracker, options)

        # Removed custom version validation code
      end #move_to_with_wrapping

    end #InstanceMethods

  end #IssuePatch

end #RedmineVersionFields

The 'self.included(base)' method is called by the including class when it includes the module; it passes itself in, so we can perform our modifications. So when we tell the Issue model to include the IssuePatch module in 'init.rb', it executes this function.

The first two lines in the function merge two modules into the class; the first module is ClassMethods , which contains methods that can be run only on the instantiated class (this module is empty); the second module is InstanceMethods , which contains methods that can only be run on instantiated objects. The InstanceMethods contains two functions which will extend existing ones using the chaining technique (discussed below).

The line 'base.alias_method_chain :validate, :wrapping' takes the existing function 'validate' and renames it to 'validate_without_wrapping'; it then aliases 'validate' to 'validate_with_wrapping', which is provided by the InstanceMethods module. As you can see, this method first calls the original function to perform all of the standard validation, then performs some more validation (which has been removed here).

Finally, the 'base.class_eval do ... end' block defines two extra ActiveRecord associations, indicating that each Issue has two more Versions in addition to the one included by default. It may seem strange to use a "belongs_to" relationship rather than a "has_one" relationship, but if you read http://guides.rubyonrails.org/association_basics.html#the-belongs-to-association, you will see it makes sense.

The difference, as far as I can tell, between 'base.class_eval' and 'base.extend' is that base.class_eval seems to splice the code directly into the class definition as if the code in this block had been included in the original source file; modules added by base.extend are stored separately and logically merged at run time. I'm not sure why we use both styles - the patch is modeled on another plugin by an author who is very active in the Redmine community. I assumed he knows best.

Change Manifest

There are three model patches:
  1. lib/redmine_version_fields/issue_patch.rb
    Adds the two associations discussed above, and extends the 'validate' and 'move_to' methods.
  2. lib/redmine_version_fields/query_patch.rb
    Wraps the 'available_filters' method to include the two new version fields.
  3. lib/redmine_version_fields/version_patch.rb
    Adds two 'has_many' associations - these are the other end of the two associations added in issue_patch.rb

The 'db/migrate/010_add_extra_version_fields.rb' file modifies the 'issues' table in the database to add two more version columns.

In 'app/views/issues/ there are three files:

  1. _attributes.rhtml
    This file is an exact copy of the same file in Redmine core, except it has one extra line:
    <%= render :partial => 'form_extra_version_fields', :locals => {:f => f, :issue => @issue} %>

    Important for upgrading Redmine: Unfortunately views can't be duck-punched, so it is necessary to duplicate the file. If you upgrade Redmine, make sure you copy any changes to the original file into this one. It's probably best to copy the new file in from Redmine core, and then re-insert the one line.

  2. _form_extra_version_fields.rhtml
    This is the partial that is included by our modification to _attributes.rhtml. It renders the two new version fields in the Issue editor.
  3. _show_extra_version_fields.rhtml
    This partial is connected to the 'view_issues_show_details_bottom' hook in init.rb. This hook fires when Redmine has finished rendering all of the attributes when displaying an Issue; it simply adds one more row to the table and renders the two new version fields.

plugin.tar - The source code (31 KB)

RE: Plugin Architecture Help - Added by Pascal Schoenhardt over 7 years ago

Oh, I've also got another section in our wiki which is a quick intro to model/view/controller in RoR. I don't know if you're new to this like I was, but I will post that too. It's nothing you couldn't find online yourself, but it should save you a couple of hours:

Model/View/Controller Overview

A model represents a data object; an example is "Issue". The model file describes things like "each issue has 0 or more comments," "each issue belongs to exactly one project," "an issue has a Description property," "the Title property is required," etc. In Ruby on Rails, the models live in the 'app/models' directory under the web root.

A view is essentially a set of special HTML files with small fragments of embedded Ruby code. For example, the directory 'app/views/issues/' contains many .rhtml files. The files that begin with an underscore are partials - small fragments of HTML/Ruby that can be included in other views to eliminate code duplication. The other files represent the rendering templates for the various actions; for example, the index.rhtml file renders the main issues list; the gantt.rhtml file renders the Gantt chart.

A controller essentially defines the actions that are available on a model, and maps them to views. It also exposes model data for each action which the view can then access. For example, the issues_controller.rb in 'app/controllers' defines a method called "index" which finds all issues for the current query, exposes data structures like @query, @issue_count, @issue_pages, @issues, etc, and then executes the appropriate view based on the request format (HTML, XML, Atom, CSV, or PDF).

The URL in a Ruby on Rails app integrates very closely into this paradigm. Consider the following example:

http://redmine/issues/gantt

This URL executes the gantt action on the issues controller, which in turn renders the gantt view for the user.

A more complex example:

http://redmine/projects/dummy-cvs/repository/revisions/73170/entry/makefile.mak
Maps to:       model    ID        model      model     ID    action param

The general rule for URL syntax is that either an action or an identifier can follow a model, a model can follow an identifier, and a parameter can follow an action.

In this URL, since 'dummy-cvs' isn't an action in the projects_controller.rb file, Ruby on Rails takes it as an identifier and loads that specific project object into memory. Next it load the specific 'repository' object for that project, and then accesses the revision list for that repository. Since 73170 is not an action in revisions_controller.rb, it is read as an identifier and that specific revision object is loaded; 'entry' is an action in revisions_controller.rb, so it is executed with the parameter "makefile.mak" and renders this revision's version of the file '/cvs/cvsmain/makefile.mak' using the view 'app/views/repositories/entry.rhtml'.

How Plugins Work

Under the web root, there is a directory called 'vendor/plugins/' - each subdirectory thereof is a plugin; our plugin is 'redmine_version_fields'. Each plugin has a file called 'init.rb' in its root, which registers the plugin and runs any initialization code. Each plugin's directory replicates the web root: there are 'app/models', 'app/views', and 'app/controllers' directories.

Ruby on Rails will give any model, view, or controller in an active plugin priority over the core components. IE: The original model/view/controller is only loaded if it is not provided by a plugin. I'm not sure how it decides between competing implementations by multiple plugins - I assume that last loaded plugin wins, which is probably alphabetical. I imagine if too many plugins are installed, you will start to see strange behaviour.

For a detailed tutorial on how to create a plugin, see this guide: http://www.redmine.org/wiki/redmine/Plugin_Tutorial

Duck Punching

Duck Punching (a.k.a. Monkey Patching) is a nickname for dynamically patching/modifying an class at runtime, instead of patching the source code. This is what allows plugins to be very powerful in Ruby on Rails, since a plugin is not limited to some API provided by the core application, but can modify anything. Obviously this can be quite dangerous, too. The name "Duck Punching" comes from Ruby's type system: objects are not given an explicit type; instead, the type is inferred from the object's signature. This is known as duck-typing, since "if it looks like a duck, and acts like a duck, it's probably a duck." So why punching?

Well, I was just totally sold by Adam, the idea being that if it walks like a duck and talks like a duck, it’s a duck, right? So if this duck is not giving you the noise that you want, you’ve got to just punch that duck until it returns what you expect.

– Patrick Ewing (A Ruby on Rails contributor, not the NBA player)

RE: Plugin Architecture Help - Added by John Fisher over 7 years ago

Thanks, Pascal! I'll chew over this in coming days and post up if I get stuck.

John

(1-9/9)