Topic: has_many :through

Hi everybody,
I'm new to RoR and try to build an application that manages events. An event has many volunteers and a volunteer can participate on multiple events. So I have a many-to-many association. I called the third table "invitations". I found really a lot of tutorials that explained how to setup a has_many :through association but none of them covered how to setup an appropriate view to manage these associations. Using the console everything works fine but I would really really like to use a view for this.

Does anybody know a tutorial therefor in which a view is created?
Or can somebody maybe post some line of codes that are needed to update the table "invitations" and the extra attributes in there?

thx a lot

Last edited by nomawie (2010-07-19 13:03:47)

Re: has_many :through

Just popped in here quick but in my usage of has_many :through I usually just manage them through the parent view via the association you create.

in event view
i.e.
@event.invitation.each do |a|
  a.first_name
  a.last_name
end

I'm sure someone else will give you a better answer shortly but I at least wanted to start you down a path.

"If I'd asked my customers what they wanted, they'd have said a faster horse." - Henry Ford

Re: has_many :through

If you want to manage invitations in the form for events, then the accepts_nested_attributes_for method is just what you need.

This might help:
http://railscasts.com/episodes/196-nest … orm-part-1
http://railscasts.com/episodes/197-nest … orm-part-2

Re: has_many :through

ok I got something to work using this code in the update method

@event.volunteers = [] 
@volunteers = Volunteer.find(params[:volunteers][:ids])
@volunteers.each do |v|
      v.events << @event
end

and this in the view

  <% for volunteer in Volunteer.find(:all) %>
    <%= check_box_tag "volunteers[ids][]" ,volunteer.id, @event.volunteers.include?(volunteer)%>
    <%= volunteer.first_name %>
  <% end %>

pretty easy. Don't know if this is the preffered way of coding this. Feel free to correct me. But in my third (joining) table "invitations", I have an additional field called "accepted". When a volunteer gets an invitation, this field should be set to 1. How can I do this?

@bluesman.alex: accepts_nested_attributes_for looks pretty interesting but I guess currently this is too much for my needs and experiences

--edit--
ok, I got it with these lines

@inv = Invitation.find(:first, :conditions => {:event_id => @event.id, :volunteer_id => v.id})
@inv.accepted = 1
@inv.save

but this looks a little fiddly to me. Is there a better way to do this?

Last edited by nomawie (2010-07-20 11:48:01)

Re: has_many :through

nomawie wrote:

but this looks a little fiddly to me. Is there a better way to do this?

It can be simplified a little:

@event.invitations.find_by_volunteer_id(v.id).update_attributes :accepted => 1

But, to me it looks way too complex anyway. I prefer to keep controller as dumb as possible and make models do all the magic. So what I would do is I would:
1) Make a controller action look like:

def update
  @event = Even.find params[:id]
  if @event.update_attributes(params[:event])
    flash[:notice] = 'Success, blahblahblah'
    redirect_to <whereever>
  else
    render 'edit'
  end
end

2) Make it work adding neccessary definitions to the model. In this case:

def volunteer_ids=(values)
  values.each do |id|
    volunteers << Volunteer.find(id)
    invitations.find_by_volunteer_id(id).update_attributes :accepted => 1
  end
end

And I'd not use check_box_tag method, but I'd use this plugin instead: http://github.com/justinfrench/formtastic (which is cool, not only for this case)
and in my form I'd have something like this for handling invitations:

  f.input :volunteer_ids, :as => :check_boxes

And to make it work, you will also need to add the following to your Event model:

  def volunteer_ids
    volunteers.map &:id
  end

Hope this helps...

update
Actually the has_many :through association does provide the volunteer_ids= and volunteer_ids methods, so you only need to hook it with the code that sets (and probably clears too?) the accepted flag

Last edited by bluesman.alex (2010-07-20 13:24:05)

Re: has_many :through

I really like your idea of delegating the logic to the model but I don't think that it will work that way, because the method 

volunteer_ids=(values)

will only be used when a new invitation is created. But the attribute accepted will not be changed after an update. That way "accepted" is always set to 1.

Maybe I didn't get exactly what you mean. You would overwrite the method in the model invitation.rb, right?

Re: has_many :through

nomawie wrote:

You would overwrite the method in the model invitation.rb, right?

nomawie wrote:

...the method 

volunteer_ids=(values)

will only be used when a new invitation is created. But the attribute accepted will not be changed after an update.

I meant to overwrite the volunteer_ids= method in Event model. It will be used each time you do a mass assignment on your event.

Could you explain once again please, which controller and which action should update the accepted flag, and what should be the logic for that?

Re: has_many :through

Accepted is just a small status flag

accepted  | volunteer
-------------------------
      0      | not invited
      1      | invited
      2      | inv. accepted
      3      | inv. rejected

Re: has_many :through

Okay, so if you have a set of checkboxes in your event form, what should it reflect?
We can think of that as a checked box reflects that this event is associated with the corresponding invitation, and if its not checked then there is no invitation for that volunteer. I'd say its probably the same as "not invited status".

With that approach you don't even have to override the volunteer_ids= method.

Am I getting something wrong? I actually still don't quite understand what logic you want.

Re: has_many :through

Ok, I'll explain it a little more detailed and give my current solution which works the way I want it to. I know the code I wrote is absolutely beginner style and the algorithms are not efficient. But unfortunately I am a beginner in Ruby on Rails. I'll try to optimize this in the next days

The idea is that a manager can invite volunteers to an event. The invitation can be accepted or rejected by the volunteers. The models:

class Event < ActiveRecord::Base
  has_many :invitations
  has_many :volunteers, :through => :invitations  
end
class Volunteer < ActiveRecord::Base
  has_many :invitations
  has_many :events, :through => :invitations
end
class Invitation < ActiveRecord::Base
  belongs_to :event
  belongs_to :volunteer
end

update method of events_controller.rb

  def update
    @event = Event.find(params[:id])

    respond_to do |format|
      if @event.update_attributes(params[:event])
        format.html { redirect_to(@event, :notice => 'Event was successfully updated.') }
        format.xml  { head :ok }
      else
        format.html { render :action => "edit" }
        format.xml  { render :xml => @event.errors, :status => :unprocessable_entity }
      end
    end
    
    if params[:volunteers] && !params[:volunteers].empty?
      
      @volunteers = Volunteer.find(params[:volunteers][:ids])
      @volunteerIds = @volunteers.map(&:id)
      
      # cancel invitations 
      @current = @event.invitations.find(:all).map(&:volunteer_id)
      @current.each do |vId|
        if @volunteerIds.find{|e| e==vId}.nil?
          logger.info("DESTROYING #{vId}")
          Invitation.find(:first, :conditions => "volunteer_id = #{vId}").destroy
        end
      end
      
      #handle new invitations
      @volunteers.each do |v|              
        if @event.invitations.find(:all, :conditions => "volunteer_id = #{v.id}").empty?
          v.events << @event
          logger.info("ADDING #{v.id}")
          @event.invitations.find_by_volunteer_id(v.id).update_attributes :accepted => 1
        end
      end
      
    end
  end

Two additional functions (which should definitly be merged) in volunteers_controller

  def accept
    @volunteer = Volunteer.find_by_id(params[:id])
    @volunteer.invitations.find_by_event_id(params[:event_id]).update_attributes(:accepted => 2)
    redirect_to :action => "show", :id => params[:id] 
  end
  
  def cancel
    @volunteer = Volunteer.find_by_id(params[:id])
    @volunteer.invitations.find_by_event_id(params[:event_id]).update_attributes(:accepted => 3)
    redirect_to :action => "show", :id => params[:id]
  end

the checkboxes, which the manager uses to invite the volunteers in the event edit view

  <p> 
  <% for volunteer in Volunteer.find(:all) %>
    <%= check_box_tag "volunteers[ids][]" ,volunteer.id, @event.volunteers.include?(volunteer)%>
    <%= volunteer.first_name %>
  <% end %>
  </p>

and the two links for the volunteer to reject or accept in the show view

<p>
  <b>Events</b>
  <ul>
  <% @volunteer.events.each do |event| %>
    <li>
      <%= link_to event.title, event %> 
      (<%= link_to "Accept", {:controller => "volunteers", :action => "accept", :id => @volunteer.id, :event_id => event.id} %>,
      <%= link_to "Reject", {:controller => "volunteers", :action => "cancel", :id => @volunteer.id, :event_id => event.id} %>)
    </li>
  <% end %>
  </ul>
</p>

I know there is a lot to do concerning code optimization and making the user interface more comfortable but right now I am happy that I now manage the relationships of the models correctly. Maybe this beginner-style code helps other beginners like me to understand the way the has_many => :through relation works

Re: has_many :through

Okay, its mostly cool except that the code after the respond_to block in events_controller should not be there.

If you change chis:

<%= check_box_tag "volunteers[ids][]" ,volunteer.id, @event.volunteers.include?(volunteer)%>

to this:

<%= check_box_tag "event[volunteers_ids][]" ,volunteer.id, @event.volunteers.include?(volunteer)%>

(ids of the volunteers should be available as params[:event][:volunteer_ids] after that) then creation and destroying of invitations will be handled automatically. If you really really really want it to be logged you can do it in your Invitation model like this:

after_create :log_creation
after_destroy :log_destroy

def log_creation
  #not sure if the logger method is available to model
  RAILS_DEFAULT_LOGGER.info 'Invitation added, volunteer_id: #{volunteer_id}'
end

def log_destroy
  RAILS_DEFAULT_LOGGER.info 'Invitation destroyed, volunteer_id: #{volunteer_id}'
end

And to make its status be 1 after creation I would simply provide a default value in the migration. There are also other ways to do it - overriding the reader method to provide default or adding it as a before_create hook etc.

And another thing about your code, is that you could make it a little bit cleaner if you
1) Move accept and reject logic inside Invitation model:

class Invitation < ActiveRecord::Base
  def accept
    accepted = 2
    save
  end

  def reject
    accepted = 3
    save
  end
end

2) Make a separate controller called InvitationsController to perform accept and reject actions. These actions will only need invitation id. This way it would be more clear.

InvitationsController < ApplicationController
  def accept
    @invitation = Invitation.find(params[:id])
    @invitation.accept
    redirect_to :controller => :events, :action => "show", :id => @invitation.event_id
  end

  def reject
    @invitation = Invitation.find(params[:id])
    @invitation.reject
    redirect_to :controller => :events, :action => "show", :id => @invitation.event_id
  end
end

Last edited by bluesman.alex (2010-07-21 22:17:04)

Re: has_many :through

Everything works fine and it really shortens my code. The only thing you forgot in the models was "self"

  def accept  
    self.accepted = 2
    self.save
  end
  
  def reject
    self.accepted = 3
    self.save
  end

Thx a lot for your advices