views:

120

answers:

1

I have a standard Rails application.

When a Tip is created, I would like to create a Message for each User who is interested in that Tip.

This sounds simple right? It should be...

So, we start with a Tip Observer:

class TipObserver < ActiveRecord::Observer
  def after_save(tip)
    # after the tip is saved, we'll create some messages to inform the users
    users = User.interested_in(tip) # get the relevant users
    users.each do |u|
      m = Message.new
      m.recipient = u
      link_to_tip = tip_path(tip)
      m.body = "Hello #{u.name}, a new tip: #{link_to_tip}"
      m.save!
    end
  end
end

Errors:

tip_observer.rb:13:in `after_save': undefined method `tip_path' for #<TipObserver:0xb75ca17c> (NoMethodError)

Ok, so TipObserver needs access to the UrlWriter methods. This should be fairly straightforward to fix, right?

class TipObserver < ActiveRecord::Observer
  include ActionController::UrlWriter

Now it runs(!) and Outputs:

Hello dave18, a new tip: /tips/511

Great that works!! Well it kinda, really we want that to be a click-able link. Again, that should be easy right?

link_to_tip = link_to tip.name, tip_path(tip)

Errors:

tip_observer.rb:13:in `after_save': undefined method `link_to' for #<TipObserver:0xb75f7708> (NoMethodError)

Ok, so this time TipObserver needs access to the UrlHelper methods. This should be fairly straightforward to fix, right?

class TipObserver < ActiveRecord::Observer
  include ActionController::UrlWriter
  include ActionView::Helpers::UrlHelper

Errors:

whiny_nil.rb:52:in `method_missing': undefined method `url_for' for nil:NilClass (NoMethodError)

Ok, it seems adding that has interfered with the url_for declaration. Lets try the includes in a different order:

class TipObserver < ActiveRecord::Observer
  include ActionView::Helpers::UrlHelper
  include ActionController::UrlWriter

Errors:

url_rewriter.rb:127:in `merge': can't convert String into Hash (TypeError)

Hmm, there's no obvious way around this. But after reading some clever-clogs suggestion that Sweepers are the same as Observers but have access to the url helpers. So lets convert the Observer to a Sweeper and remove the UrlHelper and UrlWriter.

class TipObserver < ActionController::Caching::Sweeper
  observe Tip
  #include ActionView::Helpers::UrlHelper
  #include ActionController::UrlWriter

Well, that allows it to run, but here's the Output:

Hello torey39, a new tip:

So, there's no error, but the url is not generated. Further investigation with the console reveals that:

tip_path => nil

and therefore:

tip_path(tip) => nil

Ok well I have no idea how to fix that problem, so perhaps we can attack this from a different direction. If we move the content into an erb template, and render the Message.body as a view - that gives two benefits - firstly the "View" content is put in the correct location, and it might help us avoid these *_path problems.

So lets change the after_save method:

def after_save(tip)
  ...
  template_instance = ActionView::Base.new(Rails::Configuration.new.view_path)
  m.body = template_instance.render(:partial => "messages/tip", :locals => { 
      :user=>user, 
      :tip=>tip
    })
  m.save!
end

Errors:

undefined method `url_for' for nil:NilClass (ActionView::TemplateError)

Great, but now we're back to this bloody url_for again. So this time its the ActionView thats complaining. Lets try and fix this then:

def after_save(tip)
  ...
  template_instance = ActionView::Base.new(Rails::Configuration.new.view_path)
  template_instance.extend ActionController::UrlWriter

Errors:

undefined method `default_url_options' for ActionView::Base:Class

Great so whatever we do we end up with errors. I've tried many many way of assigning default_url_options inside the template_instance without success.

So far this doesn't feel very "Railsy", in fact it feels downright difficult.

So my question is:

  • Am I trying to get a square peg in a round hole? If so, how should I adapt the architecture to provide this functionality? I can't believe its not something that exists in other websites.
  • Should I give up trying to use an Observer or Sweeper?
  • Should I be trying to create new Messages via the MessagesController, and if so, how can I invoke the MessagesController directly and multiple times from within the Observer/Sweeper?

Any tips advice or suggestions would be very gratefully recieved, I have been banging my head against this brick wall for several days now and slowly losing the will to live.

tia

Keith

+2  A: 

Well you are right that your approach isn't very Rails-y. You are trying to mix model, controller and view methods in a way they aren't designed to and that's always a little shaky.

If I had started down your path, I probably would have given up at the link_to problem and (I admit it isn't "the Rails way") coded the HTML for the link manually. So link_to_tip = link_to tip.name, tip_path(tip) becomes link_to_tip = '<a href="#{tip_path(tip)}">#{tip.name}</a> - a quick and dirty solution if you're looking for one ;-)

But in my experience, Rails is pretty neat until you want to do things in a non-standard way. Then it can bite you :-)

The problem is you are writing and storing text in your Message model which shouldn't be there. The Message model should belong_to Tips and a view should be responsible for presenting the message text, including the link to the tip. If a Message can be about something other than Tips, you can make a polymorphic association in the Message model like this:

belongs_to :source, :polymorphic => true

The Tip model would include:

has_many :messages, :as => :source

Then you do this (using your code as an example):

m = Message.new
m.source = tip
m.save!

The view which renders the message is then responsible for creating the link, like this:

<%= "Hello #{u.name}, a new tip: #{link_to m.source.name, tip_path(m.source)}" %>
StefanO
Thanks StefanO, i was hoping there's a clean way to achieve this - i can't believe that creating preconfigured objects is so hard. I mean it feels like i want something like FactoryGirl or Machinist, but those are testing tools right? And they wouldn't help me with rendering view to string would they?
Globalkeith
I updated my post to give you a hint of the solution I arrived at when faced with an almost identical problem like you describe ;-) In addition to the code, I would add a message_link method to each model which can be a message source to simplify your views (no need to check the source type).
StefanO
Thanks again StefanO. I understand what you've done but I really don't want to have to create a connection between my Message class and Tip class, just to achieve a link, THAT is definitely not Railsy!! My app uses Messages for various other communications that don't involve Tips. Unless anyone is able to come up with another suggestion I'm going to look at writing something similar to the Publisher class from the facebooker library here: http://facebooker.rubyforge.org/svn/trunk/facebooker/lib/facebooker/rails/publisher.rb
Globalkeith