Frage

I'm writing a booking system which handles recurring bookings using the ice_cube gem. A Booking has_many BookingItems, one for each occurrence in the recurrence rule, and these are created in a method which is called by Booking's after_save callback.

This was all working fine until I added a validation to BookingItem which avoids double booking by checking that there isn't already a BookingItem at the given time. This validation raises an error which I'd like to display on the booking form but at the moment it just silently prevents the Booking from being saved - since the error is raised by the BookingItem it's not being passed back to the form for the Booking.

app/models/booking.rb

class Booking < ActiveRecord::Base
  include IceCube

  has_many :booking_items, :dependent => :destroy

  after_save :recreate_booking_items!

  # snip

  private

  def recreate_booking_items!
    schedule.all_occurrences.each do |date|
      booking_items.create!(space: self.requested_space, 
                            booking_date: date.to_date,
                            start_time: Time.parse("#{date.to_date.to_default_s} #{self.start_time.strftime('%H:%M:00')}"),
                            end_time: Time.parse("#{date.to_date.to_default_s} #{self.end_time.strftime('%H:%M:00')}"))
    end
  end
end

app/models/booking_item.rb

class BookingItem < ActiveRecord::Base
  belongs_to :booking

  validate :availability_of_space

  # snip

  private

    def availability_of_space
      unless space.available_between? DateTime.parse("#{booking_date}##{start_time}"), DateTime.parse("#{booking_date}##{end_time}")
        errors[:base] << "The selected space is not available between those times."
      end
    end
end

app/views/booking/_form.html.erb

<% if @booking.errors.any? %>
  <div id="error_explanation">
    <p><%= pluralize(@booking.errors.count, "error") %> prohibited this booking from being saved:</p>
    <ul>
      <% @booking.errors.full_messages.each do |msg| %>
        <li><%= msg %></li>
      <% end %>
    </ul>
  </div>
<% end %>

<%= form_for(@booking, :html => { :class => "nice custom"}) do |f| %>
  ...
<% end %>
War es hilfreich?

Lösung

Your options are somewhat limited if you use an after_save callback to create the BookingItem objects.

Instead of using after_save, I would use before_validation and make a few adjustments to accommodate that.

1) Build the BookingItem objects in a before_validation callback

before_validation :recreate_booking_items!

and

def recreate_booking_items!
  schedule.all_occurrences.each do |date|
    booking_items.build(......
  end
end

Note that I'm using build instead of create!

When you validate the Booking object, the new BookingItem objects in the booking_items collection will be validated too. Any errors will be included in the main Booking object's error collection, and you can display them in your view as you normally would because the Booking object will fail to save.

Notes

1) The BookingItem objects are validated automatically when the Booking object is validated because they are new records and belong to a has_many association. They would not be automatically validated if they were persisted (i.e. already in the database).

2) before_validation callbacks can be called more than once in the lifecycle of an object, depending on your code. In such a case, the BookingItem objects would be built each time the callback is called, which would result in duplicates. To prevent that, you can add the following line at the beginning of recreate_booking_items!:

booking_items.delete_all

Of course, you might not want to do that if you have persisted BookingItem objects in the database (see below).

3) This code is designed explicitly for creating Booking objects. If you are editing existing Booking objects that already have persisted BookingItem objects, certain modifications may be necessary, depending on your desired functionality.

UPDATE:

To address @Simon's follow up questions in the comments below.

I can think of two ways you might want do to this:

1) Keep the validation in BookingItem as you have it.

Then, I would have a custom validator in Booking like this:

validate :validate_booking_items

def validate_booking_items
  booking_items.each do |bi|
    if bi.invalid?
      errors[:base] << "Booking item #{bi.<some property>} is invalid for <some reason>"
    end
  end
end

This puts a nice custom message in Booking for each invalid BookingItem, but it also gives each BookingItem its own error collection that you can use to identify which booking_items are invalid. You can reference the invalid booking_items like this:

@booking.booking_items.select {|bi| bi.errors.present?}

Then if you want to display the invalid booking_items in your view:

f.fields_for :booking_items, f.object.booking_items.select {|bi| bi.errors.present? } do |bi|
end

The problem with this approach is that a BookingItem may be invalid for several reasons, and trying to add all of those reasons into the base Booking error collection could get messy.

Therefore, another approach:

2) Forget the custom validator in Booking. Rely on Rails' automatic validation of non-persisted members of a has_many collection to run the validation check for each BookingItem object. This will give each one of them an errors collection.

Then, in your view you can loop through the invalid booking_items and display their individual errors.

<ul>
  <% @booking.booking_items.select {|bi| bi.errors.present? }.each do |bi| %>
    <li>
      Booking item <%= bi.name %> could not be saved because:
      <ul>
        <% bi.errors.full_messages.each do |msg| %>
          <li><%= msg %></li>
        <% end %>
      </ul> 
    </li>
  <% end %>
</ul>

If you use this approach, you will have the generic "Booking items is invalid" errors in your Booking object error collection, so you'll probably want to ignore those somehow so they don't display.

Note: I'm not familiar with IceCube, but if you're displaying BookingItem objects in the form via nested_attributes_for, that might clash with building the BookingItem objects in a before_validation callback.

Lizenziert unter: CC-BY-SA mit Zuschreibung
Nicht verbunden mit StackOverflow
scroll top