I'm writing a booking system which handles recurring bookings using the ice_cube gem. A Booking
has_many BookingItem
s, 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 %>
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.