Practical 5

1. Finish Practical Sheet 4. Run the tests (rake test) to make sure everything works as it should.

2. Migrations. Add a few more books to your shopping cart, until it contains several copies of at least one book. At that point the shopping cart starts looking kind of stupid, listing the same product several times. Can those computers not keep track of the number of times a product is chosen? They can. For this, you just have to add a column to the line items table in the database.

The structure of the database can be changed by migrations, and migrations can be generated by rails. To add a quantity column of type integer to the line_items table, type the command:

rails generate migration add_quantity_to_line_items quantity:integer

then find the generated file in db/migrate. Enter the name of this file in the comment box below, as evidence that you have attended this practical.

3. Add the argument , default: 1 to the add_column function call within the change() method in this file (so that a new line item starts off with quantity set to 1, and existing line items get a value 1 for their new column quantity). Then run the migration:

rake db:migrate

4. In order to use the new quantity field, the logic behind the pages needs to be changed. Logic resides in the controller. Line items are controlled by which controller in which file? Replace the line @line_item = @cart.line_items.build(...) in the create method of this controller by

@line_item = @cart.add_product(product)

5. While Rails does a lot of things automatically, it cannot know how you intend to add a product to a cart. The method add_product needs to be defined explicitly, in the cart model (which file?). Based on the given product id, this method needs to find out whether this product is already referred to by one of the cart’s line items (this is the list line_items which exists as a consequence of the has_many :line_items declaration) and in that case increment the quantity, or create a new line item for the product and add it to the cart’s list of line items. In either case, the line item needs to be returned to the caller:

def add_product(product)
  current_item = line_items.find_by(product_id: product.id)
  if current_item
    current_item.quantity += 1
  else
    current_item = line_items.build(product_id: product.id)
  end
  current_item
end

6. Add <%= item.quantity %> &times; to the product title in the page template that displays the shopping cart (which file?). Now, add a few more products to your cart and watch the quantities increase.

6a. If all works fine, commit the changes to your local git repository, and push them to the github cloud.

git add .
git commit -m "added quantity column to line items table"
git push

7. Unfortunately, this change in the database structure does not automatically get rid of the older repeated entries. What’s needed is another migration to clean up the entire database! The command

rails generate migration combine_items_in_cart

creates the migration (where?), but doesn’t specify what to do.

8. In order to adjust all line items to the new setup, one needs to arrange a loop over all carts (Cart.all.each do |cart| ... end). Inside the loop, it has to be checked whether a product appears in several line items, and if so, these instances need to be replaced by a single line item, with the correct quantity.

Rename the method change to up in the migration file. Then insert the above loop into the up() method, and insert the following code into the loop:

# count the number of copies of each product in the cart
sums = cart.line_items.group(:product_id).sum(:quantity)
sums.each do |product_id, quantity|
  if quantity > 1

    # remove individual items
    cart.line_items.where(product_id: product_id).delete_all

    # replace with single item and record quantity
    item = cart.line_items.build(product_id: product_id)
    item.quantity = quantity
    item.save!
  end
end

Then run the migration: rake db:migrate, and reload your shopping cart. Does it look any different?

9. Migrations are normally designed so that they can be reversed if necessary. This can be specified as a single change() method which can be applied forwards and backwards (study the existing migrations in the db/migrate folder and try to imagine how each of the operations in there can be undone). Or it is specified by a pair of methods, one called up() and one called down(). Here we already have an up() method, it’s counterpart however is missing.

10. Design a down() method that undoes the latest change: it needs to replace each line item with a quantity of more than 1 by a corresponding sequence of line items with quantity

  1. (Hint: use 1, destroy, each, end, item, product_id, times, to fill the gaps.)
# split items with quantity>1 into multiple items
LineItem.where("quantity>1").______ do |item|

  # add individual items
  item.quantity.______ do
    LineItem.create(cart_id: item.cart_id,
      product_id: ______.______, quantity: ______)
  ______

  # remove original item
  item.______
end

Insert the code into its place in the migration. Then run rake db:rollback and reload the cart. Does it look different? The status of all the migrations can be listed with the command

rake db:migrate:status

Now run rake db:migrate again to reactivate the quantity counting for shopping carts.

10a. The change in output format makes a corresponding change in the tests necessary; in the "should create line_item" test, replace the expected title of the book by

"1 × Programming Ruby 1.9 & 2.0"

Then, if all works fine, commit the changes to your local git repository, and push them to the github cloud.

11. Error Handling. Under the hood, our application receives its commands in the form of HTTP requests. These requests can be typed directly into the URL field of the browser, and might not have the expected format. Try to open the page http://localhost:3000/carts/2019, that is a cart with 2019 as its id: the application raises a RecordNotFound error and exhibits its inner workings.

12. In order to handle this error, we can add a rescue_from clause to the controller and specify what to do in a new method invalid_cart(). Add the line

rescue_from ActiveRecord::RecordNotFound, with: :invalid_cart

to the top of the carts controller (which file?), and the method

def invalid_cart
  logger.error "Attempt to access invalid cart #{params[:id]}"
  redirect_to store_url, notice: 'Invalid cart'
end

to the private section of the controller. This method will now be executed when the controller tries to recover from a RecordNotFound error. It will add a line to the application’s log file log/development.log, and it prepares a notice which the user will see as part of the next page that is shown. Try to open the page http://localhost:3000/carts/2019 again, and watch the difference. Also check the log file for that message.

13. Non-existing carts are not the biggest problem. Criminals could try and get access to other peoples existing carts. In order to prevent this, let’s remove cart_id from the list of permitted parameters in the line items controller: replace the one line in line_items_params by

params.require(:line_item).permit(:product_id)

Match this change in the line items controller test by changing a line in the "should update lile item" test to

patch line_item_url(@line_item), params: {
  line_item: { product_id: @line_item.product_id }
}

Now run the tests as

rake test:controllers

and find a line about Unpermitted parameters in the log/test.log file. Note that this did not make the test fail. Remove the reference to cart_id in the "should update line item" test to fix this.

13a. If all works fine, commit the changes to your local git repository, and push them to the github cloud.

14. Deleting Objects. There needs to be a way to empty the cart. One possible solution is to simply delete the entire cart from the database. Add this button

<%= button_to 'Empty cart', @cart, method: :delete,
      data: { confirm: 'Are you sure?' } %>

to the cart display page. (Which file? Where in the file?) DON’T PUSH THE BUTTON yet.

15. Clicking this button will eventually invoke the destroy action of the cart controller. (Which file? Where in the file?) Modify this method as follows: Replace the line @cart.destroy by

@cart.destroy if @cart.id == session[:cart_id]
session[:cart_id] = nil

And replace the format.html block with

format.html { redirect_to store_url, notice: 'Your cart is currently empty' }

Now try and empty your cart, and then go back and shop for more.

16. The corresponding test needs to be updated (why?). Add the lines

post line_items_url, params: { product_id: products(:ruby).id }
@cart = Cart.find(session[:cart_id])

to the top of the assert_difference block of the "should destroy cart" test. Also replace carts_path by store_path in the assert_redirected_to command. Then run the tests and make sure it all works well.

17. Clean up. Remove the message that is automatically generated whenever a new line item is added to the cart. (Where?)

18. Replace the line items list in a cart page by a table as follows:

<h2>Your Cart</h2>
<table>
  <% @cart.line_items.each do |item| %>
    <tr>
      <td><%= item.quantity %>&times;</td>
      <td><%= item.product.title %></td>
      <td class="item_price"><%= number_to_currency(item.total_price) %></td>
    </tr>
  <% end %>

  <tr class="total_line">
    <td colspan="2">Total</td>
    <td class="total_cell"><%= number_to_currency(@cart.total_price) %></td>
  </tr>
</table>

19. This new table depends on two methods, both called total_price, one for a line item and one for a cart. Add a method total_price to the LineItem model that computes the total price as product of the product.price and the quantity. Also add the following method to the Cart model to compute the total price as the sum of the prices of the line items.

def total_price
  line_items.to_a.sum { |item| item.total_price }
end

20. Finally, we add a bit of style to the carts.scss stylesheet:

.carts {
  .item_price, .total_line {
    text-align: right;
  }

  .total_line, .total_cell {
    font-weight: bold;
    border-top: 1px solid #595;
  }
}

Which further improvements should be made? (Use the comment box below to make some suggestions.)

20a. If all works fine, commit the changes to your local git repository, and push them to the github cloud.

Written on November 11, 2020 by CS424.