Created as part of the Odin Project curriculum. View live page.
Search and book flights for Odin Air's flight offerings of November, 2021. The drop down menus for Origin, Destination, and Date are generated based on data from the relevant database tables. If no option is selected, the search is performed without that parameter (i.e. all fields left blank would return all flights, though I limited the results to 1,000).
There is a many to many relationship between bookings and passengers. Bookings can be searched by confirmation number or by email.
I wrote basic RSpec tests for the models and for the Bookings Controller
.
Part 2 of this project was to send a confirmation email to all passengers after a new booking is made. In the development environment, the letter_opener
gem is used to view the delivered email. In production, Yahoo Mail delivers mail over SMTP protocol.
I wanted a search's parameters to still be reflected in the select menus when the results were displayed. It is fairly straightforward to do this in HTML, but I found it difficult using Rails helpers since that functionality seems designed around building models as part of a POST#create flow. I have a feeling the best way to do this would be to make a Search
model and have the search form build an instance of it. That way validations could be customized for the search and Rails would perform its normal behind the scenes work. I toyed with the idea, but decided to just have the form build a Flight
object. I only needed to add attr_accessor :tickets
to the model and include both search_params
and flight_params
in the Flights Controller
(one for sending the search form and one for actually creating a Flight
object). I originally had attr_accessor :passengers
, which caused a lot of confusion when I later added a passengers
association to the model.
Dealing with nested forms was made especially challenging because of the many to many relationship between bookings
and passengers
and the uniqueness constraint on the email
column. Rails figures out how to create the PassengerBooking
join table entries on its own, which is nice. If a booking is made with an existing passenger
(matching both name
and email
), that existing passenger is assigned to the booking. If a booking is made with an existing email
but a name
that does not match, the booking form will be rendered again with the uniqueness error shown.
There ended up being a fairly easy solution to achieve this presented in this article: a before_save
callback:
def find_or_create_passenger
self.passengers = self.passengers.map do |passenger|
Passenger.find_or_create_by(email: passenger.email, name: passenger.name)
end
end
I had to switch to a before_validation
callback so that the uniqueness validation on email
wouldn't trigger first.
This worked well in most cases but I was running into a puzzling error in cases where an existing passenger is entered into the booking form, the above callback runs, and a validation error on another passenger triggers the booking form to be rendered again. At this point, params[:booking]
would look something like this:
"booking"=>{"flight_id"=>"24", "passengers_attributes"=>{"0"=>{"name"=>"ken", "email"=>"[email protected]", "id"=>11}, "1"=>{"name"=>"Beth", "email"=>"[email protected]"}}}
The first passenger now has "id" => 11
in its attribute hash, a result of find_or_create_passenger
. Even though that passenger exists and its id
is 11, this will cause the following error when calling Booking.new
:
ActiveRecord::RecordNotFound at /bookings
Couldn't find Passenger with ID=11 for Booking with ID=
I think this is related to some limitations of accepted_nested_attributes_for
as discussed here. I worked for quite a while trying to figure something out, and eventually found that a solution was to add <%= passengers_form.hidden_field :id, value: nil %>
to the new booking form, which will result in params[:booking]
looking like this after the sequence described above:
"booking"=>{"flight_id"=>"24", "passengers_attributes"=>{"0"=>{"name"=>"ken", "email"=>"[email protected]", "id"=>nil}, "1"=>{"name"=>"Beth", "email"=>"[email protected]"}}}
This will not throw the ActiveRecord::RecordNotFound
error.
I also clarified the inverse_of
relationship in the Booking
and Passenger
models because I've read sometimes that can fix issues with many to many relationships, but I don't think that made a difference here.
I originally had one datetime
column for takeoff
in the Flights
table. In order to search by date I had to separate takeoff
from the other parameters so that I could cast it as a date
(a PostgreSQL function):
Flight.where(flight_params).where('takeoff::date = ?', params[:date])
This probably would have been simpler if I weren't building off a model. But regardless, I eventually realized that this and other issues would become much simpler if I just separated the date and time into two columns.
I have not used has_and_belongs_to_many
before in a project and thought that the relationship between bookings
and passengers
was a good candidate, but I was persuaded not to by a number of articles that discouraged their use altogether. I also was concerned whether I would be able add a through
association onto a has_and_belongs_to_many
association, and it appeared that this was not possible. With has_many :through
, however, I can:
has_many :passenger_bookings
has_many :bookings through: :passenger_bookings
has_many :flights, through: :bookings
When creating the passenger_bookings
join table I made a point to use id: false
in the migration. I didn't like the idea of having an id
on a join table. However, this caused problems when calling destroy
on a Passenger
or Booking
object (which had dependent: :destroy
on the association). Apparently Rails cannot run destroy
on records without an id
, as discussed here and here. The stackoverflow link has the suggestion of switching to dependent: :delete_all
, which worked well for me. The best solution, however, might be to just allow join tables to have an otherwise useless id
column (also discussed on stackoverflow).
I wanted a flight's flight number to not be its id
but its chronological ranking for that day's flights. I decided to add a database column for flight_number
rather than calculate it anew each time it was needed. This means that if a new flight is created later flights that day will have incorrect flight_numbers
. Since this functionality is not on the user end, I was ok with that. After seeding the database, I can run Flight.reset_all_flight_numbers!
to ensure all flight_numbers
are correct.
A booking's
confirmation
is created by a before_save
callback and is used as the id
in a booking_path
. By overriding ActiveRecord::Base#to_param
to use confirmation
instead of id
, I was able to still use URL helpers like this: booking_path(booking)
.
I ran into a number of difficulties with mail. Calling PassengerMailer
directly from the Rails Console resulted in a NameError (uninitialized constant PassengerMailer)
until I called reload!
. This is discussed here, and seems to be an issue with Spring that can be fixed with spring stop
. Once the console recognized PassengerMailer
, though, I was also getting an error complaining about not having default_url_options
defined, even though it was. Closing the Terminal window and starting fresh solved both problems — I think this accomplishes the same thing that spring stop
would have.
Trying to get SendGrid to work was a disaster. First my app was banned I guess because I was trying to change my username from the one they generated into something I would recognize. I looked into choosing another Heroku addon instead, but all the others were either very limited in the free tier or did not have documentation for a Rails setup. I eventually decided to delete my entire app on Heroku and make a new one so that I could try SendGrid again. When I saw that heroku addons:create sendgrid:starter
successfully created a SENDGRID_USERNAME
and SENDGRID_PASSWORD
I took a break and then got banned again (I think because I didn't confirm my account with an email address quickly enough). I deleted this app as well to try one more time, and this time got a little further but couldn't finish setting up my account with 2-factor authentication, which they require, because they said my phone number was invalid (which it isn't). Certain buttons during their sign in process (like to skip 2-factor auth) just didn't do anything, the error messages were nondescriptive, and their support seems to be almost inaccessible. Google searches yielded many accounts of similar issues. In short, I don't expect to be using SendGrid in the future. I ultimately was able to send mail using no Heroku addons, just the included Rails mail
gem.
I decided to make a Yahoo Mail account [email protected]
to send from — for fun, but also because I didn't really want my Gmail accounts involved. I had a tough time finding the right SMTP settings to get it to work, but eventually did (view in config/production.rb
). Another important key was to make an app password, explained here. I knew to look for that because the Rails Guide mentions it for Gmail, but I was thrown off by Yahoo's description of it being for "1 time use."
Adding protocol: 'https'
to the production default_url_options
makes URL links go to the secure version of the app. (I hadn't realized before that Heroku apps are on both http
and https
.)
-Andrew Hayhurst