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.