Custom validator to prevent overlapping appointments in Rails 4 app? - ruby-on-rails

I need help writing a custom validation to prevent overlapping appointments in a Rails 4 app. I'm coding this app to teach myself Ruby & Rails. While researching the issue, I discovered a gem called ValidatesOverlap, but I want to write my own validator for learning purposes.
My Appointment model has an "appointment_at" column of the datetime datatype and a "duration" column of the time datatype. The Appointment model has a "has_many :through" association with Member and Trainer models. Appointments:
belongs_to :member
belongs_to :trainer
Existing validations in the Appointment model include:
validates :member, uniqueness: {scope: :appointment_at, message: "is booked already"}
validates :trainer, uniqueness: {scope: :appointment_at, message: "is booked already"}
The custom validator needs to prevent members or trainers from scheduling overlapping appointments. Right now, I can prevent "duplicate appointments" from being saved to the database, but can't stop "overlapping" ones. For example, if trainer_1 is booked for a 1 hour appointment with member_1 which starts at 7:00 am, my model validations prevent member_2 from booking an appointment with trainer_1 for 7:00 am. However, I have no current means of preventing member_2 from scheduling a session with trainer_1 for 7:01 am! I'm working with two attributes: "appointment_at," which is the start time and "duration" which is the total time of an appointment. I'd prefer to keep those attributes/columns if I can easily calculate "end time" from the "appointment_at" and "duration" values. I haven't figured out how to do that yet :)
I'd appreciate any thoughts or suggestions about how I can approach solving the problem of overlapping appointments (without using a gem). Many thanks in advance!

I had the same problem a while ago. You need a scope :overlapping, that reads overlapping appointments for an appointment and a validator to check. This example is for a PostgreSQL DB. You have to adjust it for your DB, if you're using another DB.
class Appointment < ActiveRecord::Base
belongs_to :member
belongs_to :trainer
validate :overlapping_appointments
scope :overlapping, ->(a) {
where(%q{ (appointment_at, (appointment_at + duration)) OVERLAPS (?, ?) }, a.appointment_at, a.appointment_to)
.where(%q{ id != ? }, a.id)
.where(trainer_id: a.trainer.id)
}
def find_overlapping
self.class.overlapping(self)
end
def overlapping?
self.class.overlapping(self).count > 0
end
def appointment_to
(appointment_at + duration.hour.hours + duration.min.minutes + duration.sec.seconds).to_datetime
end
protected
def overlapping_appointments
if overlapping?
errors[:base] << "This appointment overlaps with another one."
end
end
end
Give it a try and let me know, if it helped you.

Related

Avoid double bookings for a minibus reservation system

I'm trying to build a reservation system where a customer can reserve a minibus. I've been able to get the all the data so a booking can be made.
I'm trying to avoid another user to reserve the minibus for the same day. I'm not to sure how to go about it as in new to ruby on rails.
In my reservation.rb I've got
belongs_to :vehicle
belongs_to :user
In my user.rb and vehicle.rb I've got
has_many :reservation
In my reservation controller I've got
def new
#vehicle = Vehicle.find(params[:vehicle_id])
#reservation = Reservation.new(user_id: User.find(session[:user_id]).id)
#reservation.vehicle_id = #vehicle.id
end
would I use validation to stop double reservations?
would it be something like in my reservation.rb
validates :vehicle_id, :startDate, :uniqueness => { :message => " minibus already reserved"}
Although the above will only allow the vehicle to be reserved.
Any help will be much appreciated!
As you already figured out you cannot use Rails' built-in uniqueness validator to validate that two ranges do not overlap.
You will have to build a custom validation to check this. A condition that checks if two time or date ranges A and B overlap is quite simple. Have a look at this image.
A: |-----|
B1: |-----|
B2: |---------|
B3: |-----|
C1: |-|
C2: |-|
A and B overlap if B.start < A.end && B.end > A.start
Add the following to your model:
# app/models/reservation.rb
validate :reservations_must_not_overlap
private
def reservations_must_not_overlap
return if self
.class
.where.not(id: id)
.where(vehicle_id: vehicle_id)
.where('start_date < ? AND end_date > ?', end_date, start_date)
.none?
errors.add(:base, 'Overlapping reservation exists')
end
Some notes:
You might need to adjust the naming of the database columns and the attributes names because I wasn't sure if it was just a typo or if you use names not following Ruby conventions.
Furthermore, you might need <= and >= (instead of < and >), depending on your definition of start and end.
Moving the condition into a named scope is a good idea and will improve readability
You're gonna want to use the uniqueness validator lie you're already doing but use the scope option.
The example they give on that page is pretty similar to your use case:
class Holiday < ApplicationRecord
validates :name, uniqueness: { scope: :year,
message: "should happen once per year" }
end
As to which column you should validate, it doesn't really matter. Since the uniqueness scope is going to be all three columns, it can be any of them:
validates :vehicle_id, uniqueness, { scope: [:startDate, user_id], message: "your message" }
You should also add indexes to the database as described here (this question is very similar to yours by the way).

Rails validation / scope on 24 hour period

I'm trying to put a validation on a record. The validation will check that the record can't be created if the ip_address and post_id are the same. This works good.
I am trying to add another condition that will allow this duplication only if it is after a 24 hour period, if so, allow it to save, but all future saves will be disabled again until the 24 period is over.
Here has been my best attempt so far:
validates_uniqueness_of :ip_address, scope: :impressionable_id,
conditions: -> {where('created_at < ?', Time.now - 24.hours)}
So the validation should somehow only check the "latest" record of the group it finds to do the validation to be accurate
Thank you!
It might be easier to just make an explicit validator method for this:
class Foo < ActiveRecord::Base
validate :ip_address_uniqueness, on: :create
# ...
def ip_address_uniqueness
existing = Foo.where(impressionable_id: self.impressionable_id)
.where(ip_address: self.ip_address)
.where(created_at: Time.current.all_day)
errors.add(:ip_address, 'cannot be used again today') if existing
end
end

Rails race condition uniqueness intersection dates

I am having trouble figuring out how to solve race condition in this scenario.
I am using rails 3.2.11 with active record
I have the models: (just an example)
class Task < ActiveRecord::Base
belongs_to :person
attr_accessible :start_date, :end_date, :person_id
end
class Person < ActiveRecord::Base
has_many :tasks
end
The problem is:
I want to ensure that one person only have 1 task assigned between that interval (start_date and end_date)
For example,
Task | Person_id | start_date | end_date
0 1 01-01-01 10:00 01-01-01 11:00
1 1 01-01-01 10:15 01-01-01 10:45 ## this should no be valid!!
I know that validations in Rails don't prevent this because of race conditions, and I cannot add index at database level because I dont want the unique => true but instead some sort of unique_in_interval => true
Could someone explain how to accomplish this?
Thanks in advance.
Just to give you the idea, use after_create callback to check the uniqueness of the field just inserted during that conditional interval.
So in your tasks model,
attr_accessor :between_date
after_create :discard_insert
def discard_insert
#same_records = Person.joins(:tasks).where(:tasks=> {:between_date => start_date..end_date})
end
The above method will give you all the records with the race conditions and than you can delete the later one.
Just lock the operation using any locking mechanism like redis locker
RedisLocker.new("assign_task_#{#user.id}").run! do
#user.assign_task(#task) unless #user.has_task_within_time_period?
end

Rails Active Record Nested Attributes Validation which are in the same request

I have two models house and booking.Everything is okey over booking_date validation. But when I try to update or create multi booking in the same request. Validation can't check the invalid booking in the same request params.
Let give an example assume that booking table is empty.
params = { :house => {
:title => 'joe', :booking_attributes => [
{ :start_date => '2012-01-01', :finish_date => '2012-01-30 },
{ :start_date => '2012-01-15', :finish_date => '2012-02-15 }
]
}}
Second booking also save but its start_date is between first booking interval. When I save them one by one validation works.
class House < ActiveRecord::Base
attr_accessible :title, :booking_attributes
has_many :booking
accepts_nested_attributes_for :booking, reject_if: :all_blank, allow_destroy: true
end
class Booking < ActiveRecord::Base
belongs_to :house
attr_accessible :start_date, :finish_date
validate :booking_date
def booking_date
# Validate start_date
if Booking.where('start_date <= ? AND finish_date >= ? AND house_id = ?',
self.start_date, self.start_date, self.house_id).exists?
errors.add(:start_date, 'There is an other booking for this interval')
end
# Validate finish_date
if Booking.where('start_date <= ? AND finish_date >= ? AND house_id = ?',
self.finish_date, self.finish_date, self.house_id).exists?
errors.add(:finish_date, 'There is an other booking for this interval')
end
end
end
I google nearly 2 hours and could not find anything. What is the best approach to solve this problem?
Some resources
http://api.rubyonrails.org/classes/ActiveRecord/NestedAttributes/ClassMethods.html
http://railscasts.com/episodes/196-nested-model-form-part-1
This was only a quick 15-minutes research on my part, so I may be wrong, but I believe here's the root cause of your problem:
What accepts_nested_attributes_for does under the hood, it calls 'build' for new Booking objects (nothing is validated at this point, objects are created in memory, not stored to db) and registers validation and save hooks to be called when the parent object (House) is saved. So, in my understanding, all validations are first called for all created objects (by calling 'valid?' for each of them. Then, again if I get it right, they are saved using insert_record(record,false) which leads to save(:validate => false), so validations are not called for the 2nd time.
You can look at the sources inside these pages: http://apidock.com/rails/v3.2.8/ActiveRecord/AutosaveAssociation/save_collection_association,
http://apidock.com/rails/ActiveRecord/Associations/HasAndBelongsToManyAssociation/insert_record
You validations call Booking.where(...) to find the overlapping dates-ranges. At this point the newly created Booking objects are still only in memory, not saved to the db (remember, we are just calling valid? for each of them in the loop, saves will be done later). Thus Booking.where(...) which runs a query against a DB doesn't find them there and returns nothing. Thus they all pass valid? stage and then saved.
In a nutshell, the records created together in such a way will not be cross-validated against each other (only against the previously existing records in the database). Hence the problem you see.
Thus either save them one-by-one, or check for such date-overlapping cases among the simultaneously created Bookings yourself before saving.

In Rails 3 how can you access the user_id of the last user in the database in your model?

Edit See Below:
class Post < ActiveRecord::Base
last_user_id = User.last.id
validates_inclusion_of :user_id, :in => 0..last_user_id
end
The above solution works but as vojlo explains, once in production the code will only be executed once and the model will then validate against an incorrect range of users.
I'm working on a tutorial (rails 3.0.3) and have tried for the last half hour to figure out how to tell rails that one of the classes in my model should make sure the :user_id is within the range zero to the user_id of the last user in the database.
I know I need to be using:
validates_inclusion_of :user_id, :in 0..(can't figure out this piece)
I was able to easily ensure the number entered for User_ID in numeric with:
validates_numericality_of :user_id
I'm looking for information on where to research this, I took a good look at the ActiveRecord Validators documentation and didn't find much there.
What happens if a User deletes their account? Don't you really just want to check for existence of the user?
class Post < ActiveRecord::Base
belongs_to :user
validates_presence_of :user
...
Otherwise it's this pattern, which evaluates the post for validity against your requirements - the user_id is greater than zero and less than the largest user ID currently in the database:
class Post < ActiveRecord::Base
include ActiveModel::Validations
validates_with UserIdValidator
...
class UserIdValidator < ActiveModel::Validator
def validate(record)
max_user = User.find(:one, :order=>["id"])
unless(user_id > max_user.id && user_id > 0)
record.errors[:base] << "This record is invalid"
end
end
end
But I still don't quite understand why you would want to do this - is there something particularly special about your user id? I'd recommend the first approach.
Range from zero to last user id? The only way is writing a new validator.
I don't understand your purpose, but check out validates_associated.
Since you're just doing this for learnings sake:
(A)
Check for "last id" presuming it is the "largest id value":
class Post < ActiveRecord::Base
last_user_id = ActiveRecord::Base.connection.select_one('SELECT MAX(ID) AS "MAX_ID" FROM users')["MAX_ID"]
validates_inclusion_of :user_id, :in => 0..last_user_id
end
or
(B) MAX() will get the max value of the ID field. This may not necessarily be the "latest" record inserted. If you really want to get the "last user inserted" you could do this (checks for the latest time of insertion of the record):
class Post < ActiveRecord::Base
last_user_id = ActiveRecord::Base.connection.select_one('SELECT ID AS "LAST_ID" FROM users WHERE created_at = (SELECT MAX(created_at) from users LIMIT1)')["LAST_ID"]
validates_inclusion_of :user_id, :in => 0..last_user_id
end
Why not use validates_uniquness_of :user_id?
Assuming that the range of 0..the_latest_user_id contains all existing user ids, then you are simply looking to see if it is unique. Determining its uniqueness would be the same thing as determining if it is in that range.

Resources