Rails users < membership > groups? Enter Workflow

I bet there are a fair numbers of plugins that does the user/groups membership relations, however I’m writing a quick method here because I would like to introduce you a better state machine: Workflow plugin for Ruby on Rails.

All this is tested against Rails 2.3-stable (git)

Do not fear join models; they may feel dirty at start, because we like the idea of never touching them by doing group.users, but here’s an example where touching a join model isn’t a bad idea.

I wrote this code in 10 minutes, so excuse me if it’s not highly optimized, the point really is to just illustrate Workflow.
Let’s start with a basic User and Group model:

class User < ActiveRecord::Base
  has_many :group_users, :dependent => :destroy
  has_many :groups, :through => :group_users
end
class Group < ActiveRecord::Base
  has_many :group_users, :dependent => :destroy
  has_many :users, :through => :group_users
  # Use pure AR.
  has_many :founders, :class_name => 'User', :conditions => ['group_users.role = ?', 'founder'], :through => :group_users, :foreign_key => 'user_id', :source => :user
  has_many :moderators, :class_name => 'User', :conditions => ['group_users.role = ?', 'moderator'], :through => :group_users, :foreign_key => 'user_id', :source => :user
  has_many :members, :class_name => 'User', :conditions => ['group_users.role = ?', 'member'], :through => :group_users, :foreign_key => 'user_id', :source => :user
  has_many :waiting_users, :class_name => 'User', :conditions => ['group_users.role = ?', 'waiting'], :through => :group_users, :foreign_key => 'user_id', :source => :user
  has_many :banned_users, :class_name => 'User', :conditions => ['group_users.role = ?', 'banned'], :through => :group_users, :foreign_key => 'user_id', :source => :user
  has_many :active_users, :class_name => 'User', :conditions => ['group_users.role IN (?)', %w(founder moderator member)], :through => :group_users, :foreign_key => 'user_id', :source => :user
end

Very basic. Watch out the :dependent => :destroy code in User model because you may have special logic when deleting a User. For example you may want to force the User to disband a Group first if he’s the only founder left.

Also note that I went with GroupUser to have less problems with inflections and class names, however you can use what you prefer.

But let’s take a look at the join model, shall we.

 
#  id         :integer(4)      not null, primary key
#  group_id   :integer(4)
#  user_id    :integer(4)
#  role       :string(255)
#  created_at :datetime
#  updated_at :datetime
class GroupUser < ActiveRecord::Base
  include Workflow
  ROLES = %w(founder moderator member waiting banned)
 
  belongs_to :user
  belongs_to :group
  validates_presence_of :role, :user_id, :group_id
  validates_inclusion_of :role, :in => ROLES
 
  named_scope :founders, :conditions => { :role => 'founder' }
 
  workflow_column :role
 
  workflow do
    state :waiting do
      event :accept, :transitions_to => :member
    end
    state :member do
      event :ban, :transitions_to => :banned
      event :promote_to_moderator, :transitions_to => :moderator
    end
    state :banned do
      event :accept, :transitions_to => :member
    end
    state :moderator do
      event :demote_to_member, :transitions_to => :member
      event :promote_to_founder, :transitions_to => :founder
    end
    state :founder
  end
 
  def ban(committer = nil) # an event can accept optional parameters
    # We can't ban anyone except members.
    halt! "Cannot ban this user, has role #{current_state}" unless member?
  end
 
end

A state machine like Workflow is composed of many parts, the most important being states and events.

Workflow is not ActiveRecord dependent and also works with CouchDb, however it has a nice integration and saved states will be immediately accessible in our object instance.

Workflow like other state machines enforces the state of an object, so when you try to change state on an object where an event isn’t defined it will throw an exception. We can then smartly intercept this exception and reduce clutter in our code by using rescue.

# group.rb
 
  # Returns the membership object or a user towards this group.
  def membership_of(user)
    group_users.find(:first, :conditions => { :user_id => user.id })
  end
 
  # Quick helper to check if the user have the passed role in group.
  #   @group.role_is?(current_user, 'member')
  def role_is?(user, str)
    (membership_of(user).current_state.to_s == str) rescue false
  end
 
  # Ban a user from a group.
  # The entry will be preserved in GroupUser table.
  def ban(usr)
    begin
      usr = User.find_by_username!(usr) if usr.is_a?(String) # this is entirely another exception, add other rescue based on your app.
      membership_of(usr).ban!
      true # weak point, a state operation will return nil.
    rescue Workflow::NoTransitionAllowed => e
      false
    end
  end

Then in controller in your restful actions you can easily do if @group.ban!(user) ; else ; end

Workflow provides a lot of hooks to enter the lifecycle of the object.
I placed the state machine right into the join model, resulting in a lot of flexibility.

You can perform code when entering one or all events, right before a transition and so on.
For further informations consult the plugin’s github page, it is very informative and helpful.

$1.99 domains with SSL purchase!