Skip to content

Commit

Permalink
9 compute rating amount (#19)
Browse files Browse the repository at this point in the history
* Start work on connection using Faraday for now

* Update to use post

* Add command to call google api for route info

* remove constraints around null values for rides

* Implement pojo to return data for given rides

* Update to include ride in return objects

* Add functionality to get route duration for commuting the rides from address

* Add functionality to compute the amount for a ride

* fix tests. will add further model tests in another PR

* fix rubocop errors
  • Loading branch information
mikeheft authored Jul 4, 2024
1 parent 90606f9 commit 710cd5a
Show file tree
Hide file tree
Showing 19 changed files with 293 additions and 23 deletions.
5 changes: 5 additions & 0 deletions .rubocop.yml
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,11 @@ Naming/VariableNumber:
EnforcedStyle: snake_case
Rails/InverseOf:
Enabled: false
Rails/UniqueValidationWithoutIndex:
Description: "We will enforce this via PR review. This gives false positives at the moment."
Details: "https://github.com/rubocop/rubocop-rails/issues/221"
Enabled: true
Severity: warning
Style/AccessModifierDeclarations:
EnforcedStyle: inline
Style/Documentation:
Expand Down
4 changes: 3 additions & 1 deletion app/models/address.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ class Address < ApplicationRecord
has_many :ride_destinations, class_name: "Ride", foreign_key: "to_address_id", dependent: nil, inverse_of: :to_address

validates :line_1, :city, :state, :zip_code, :place_id, :latitude, :longitude, presence: true
validates :place_id, uniqueness: true
validates :zip_code, uniqueness: { scope: %i[line_1 line_2] }

geocoded_by :full_address do |obj, results|
if (geo = results.first)
Expand All @@ -17,7 +19,7 @@ class Address < ApplicationRecord

before_validation :geocode,
if: ->(obj) {
obj.full_address.present? && %i[line_1 city state zip_code place_id latitude
obj.full_address.present? && %i[line_1 line_2 city state zip_code place_id latitude
longitude].any? do
obj.send("#{_1}_changed?")
end
Expand Down
6 changes: 5 additions & 1 deletion app/models/driver.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,11 @@ class Driver < ApplicationRecord
class_name: "DriverAddress",
dependent: :destroy,
inverse_of: :driver
has_one :current_address, through: :current_driver_address, source: :driver, dependent: :destroy
has_one :current_address, through: :current_driver_address, source: :address, dependent: :destroy

validates :first_name, :last_name, presence: true

def origin_place_id
current_address.place_id
end
end
19 changes: 18 additions & 1 deletion app/models/ride.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ class Ride < ApplicationRecord
belongs_to :from_address, class_name: "Address"
belongs_to :to_address, class_name: "Address"

validates :duration, :distance, :commute_duration, :amount_cents, presence: true
validates :duration, :distance, :commute_duration, :amount_cents, presence: true, on: :update, if: :should_validate?
monetize :amount_cents,
as: :amount,
allow_nil: false,
Expand All @@ -16,4 +16,21 @@ class Ride < ApplicationRecord
scope :by_address, ->(address_id) {
where(from_address_id: address_id).or(where(to_address_id: address_id))
}
scope :selectable, -> {
includes(:from_address, :to_address)
.select(:id, :from_address_id, :to_address_id)
.where(driver_id: nil, duration: nil, distance: nil, commute_duration: nil, amount_cents: 0)
}

def origin_place_id
from_address.place_id
end

def destination_place_id
to_address.place_id
end

private def should_validate?
duration.present? && distance.present? && commute_duration.present? && amount_cents.present?
end
end
4 changes: 3 additions & 1 deletion db/migrate/20240702182238_create_addresses.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,12 @@ def change
t.string :zip_code, index: true, null: false
t.float :latitude, null: false
t.float :longitude, null: false
t.string :place_id, null: true, index: true
t.string :place_id, null: false

t.timestamps
end
add_index :addresses, %i[city state]
add_index :addresses, %i[line_1 line_2 zip_code], unique: true
add_index :addresses, :place_id, unique: true
end
end
6 changes: 3 additions & 3 deletions db/migrate/20240702193305_create_rides.rb
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
class CreateRides < ActiveRecord::Migration[7.1]
def change
create_table :rides do |t|
t.float :duration, index: true, null: false
t.float :distance, index: true, null: false
t.float :commute_duration, index: true, null: false
t.float :duration, index: true
t.float :distance, index: true
t.float :commute_duration, index: true
t.monetize :amount
t.references :driver, null: true, index: true
t.references :from_address, foreign_key: {to_table: :addresses}, index: true, null: false
Expand Down
11 changes: 6 additions & 5 deletions db/schema.rb

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

11 changes: 11 additions & 0 deletions lib/base_command.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# frozen_string_literal: true

class BaseCommand
def self.call(**args)
new.call(**args)
end

def call(**args)
raise NotImplementedError, "Must define #call method"
end
end
23 changes: 23 additions & 0 deletions lib/client/request.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# frozen_string_literal: true

module Client
class Request
CONNECTION = Faraday
private_constant :CONNECTION

def self.connection(url:, params: {}, headers: {})
new(url, params, headers)
end

def post(url, body, headers = nil)
connection.post(url, body.to_json, headers)
end

attr_reader :connection
private :connection

private def initialize(url, params, headers)
@connection = CONNECTION.new(url, params:, headers:)
end
end
end
44 changes: 44 additions & 0 deletions lib/rides/commands/compute_amount.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# frozen_string_literal: true

module Rides
module Commands
class ComputeAmount < BaseCommand
BASE_PAY_AMOUNT = Money.new(12_00)
MILEAGE_BONUS_AMOUNT = Money.new(1_50)
MILEAGE_BONUS_CLIFF = 5.0
DURATION_BONUS_AMOUNT = Money.new(0.7)
DURATION_BONUS_CLIFF = 0.25

def call(ride:)
distance_bonus_amount = compute_distance_bonus(ride.distance_meters)
duration_bonus_amount = compute_duration_bonus(ride.duration)

BASE_PAY_AMOUNT + distance_bonus_amount + duration_bonus_amount
end

private def compute_distance_bonus(distance_meters)
distance_in_miles = convert_distance_to_miles(distance_meters)

amount = distance_in_miles > MILEAGE_BONUS_CLIFF ? MILEAGE_BONUS_AMOUNT * distance_in_miles : 0
Money.new(amount)
end

private def compute_duration_bonus(duration)
duration_in_hours = convert_duration_to_hours(duration)

amount = duration_in_hours > DURATION_BONUS_CLIFF ? DURATION_BONUS_AMOUNT * duration_in_hours : 0
Money.new(amount)
end

private def convert_distance_to_miles(distance_meters)
# 1 mile = 1609.34 meters
distance_meters / 1609.34
end

private def convert_duration_to_hours(duration)
# Since there are 3,600 seconds in one hour, that's the conversion ratio used in the formula
duration.to_f / 3600
end
end
end
end
25 changes: 25 additions & 0 deletions lib/rides/commands/get_commute_duration.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# frozen_string_literal: true

module Rides
module Commands
# Computes the duration of the commute for the ride.
# This is used in the ranking for the rides that the driver will
# choose from.
# Returns a list of objects where the origin is the driver's current address(home)
class GetCommuteDuration < BaseCommand
def call(rides:, driver:)
commute_rides = convert_rides(rides, driver)
GetRoutesData.call(rides: commute_rides)
end

# Converts the Driver's current home address and
# the Ride#from_address into structs that can be used to
# obtain the route information
private def convert_rides(rides, driver)
rides.each_with_object([]) do |ride, acc|
acc << OpenStruct.new(origin_place_id: driver.origin_place_id, destination_place_id: ride.origin_place_id)
end
end
end
end
end
67 changes: 67 additions & 0 deletions lib/rides/commands/get_routes_data.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
# frozen_string_literal: true

module Rides
module Commands
# Makes a request to the Google API to obtain the route information
class GetRoutesData < BaseCommand
DIRECTIONS_API_URL = "https://routes.googleapis.com/distanceMatrix/v2:computeRouteMatrix"
DEFAULT_HEADERS = {
"X-Goog-FieldMask" => "originIndex,destinationIndex,status,condition,distanceMeters,duration",
"X-goog-api-key" => ENV["GOOGLE_API_KEY"],
"Content-Type" => "application/json"
}.freeze
DEFAULT_REQUEST_PARAMS = { routingPreference: "TRAFFIC_AWARE", travelMode: "DRIVE" }.freeze

def call(rides:)
data = get_direction_data_for_ride(rides)
results(data, rides)
end

# Returns a list of objects, with attributes of
# @param[:distance_meters] = Integer
# @param[:duration] = String, e.g., "577s"
# Duration is in seconds
private def results(data, rides)
# The response keeps the array positioning on the return. Since we're getting a matrix
# of routes back, we only want the ones where we explicitly have a 'Ride'. This means that
# we want the computations where the indicies match.
data = data.select { _1[:originIndex] == _1[:destinationIndex] }
data = transform_keys!(data)

data.map.with_index { OpenStruct.new(ride: rides[_2], **_1) }
end

private def connection
@connection ||= Client::Request.connection(
url: DIRECTIONS_API_URL,
headers: DEFAULT_HEADERS
)
end

private def get_direction_data_for_ride(rides)
body = build_request_body(rides)

response = connection.post(
DIRECTIONS_API_URL,
body.merge(routingPreference: "TRAFFIC_AWARE", travelMode: "DRIVE")
)

JSON.parse(response.body, symbolize_names: true)
end

private def build_request_body(rides)
rides.each_with_object({}) do |ride, acc|
acc[:origins] ||= []
acc[:destinations] ||= []

acc[:origins] << { waypoint: { placeId: ride.origin_place_id } }
acc[:destinations] << { waypoint: { placeId: ride.destination_place_id } }
end
end

private def transform_keys!(data)
data.map { |d| d.transform_keys { |k| k.to_s.underscore.to_sym } }
end
end
end
end
2 changes: 2 additions & 0 deletions spec/factories/drivers.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,7 @@
factory :driver do
first_name { Faker::Name.first_name }
last_name { Faker::Name.last_name }

association :current_address, factory: :address
end
end
8 changes: 4 additions & 4 deletions spec/factories/rides.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,10 @@

FactoryBot.define do
factory :ride do
duration { 2.3 }
commute_duration { 1.0 }
distance { 30.1 }
amount_cents { 1200 }
duration { nil }
commute_duration { nil }
distance { nil }
amount_cents { 0 }

driver { nil }

Expand Down
25 changes: 25 additions & 0 deletions spec/lib/rides/commands/compute_amount_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# frozen_string_literal: true

RSpec.describe Rides::Commands::ComputeAmount do
let(:duration) { "577s" }
let(:distance_meters) { 3105 }
subject { described_class.call(ride:) }

describe "CSU to The Still" do
let(:ride) { OpenStruct.new(duration:, distance_meters:) }
it "computes the amount" do
result = subject
expect(result.format).to eq("$12.00")
end
end

describe "Fort Collins to Denver" do
let(:distance_meters) { 100_262.1 }
let(:duration) { "3600s" }
let(:ride) { OpenStruct.new(duration:, distance_meters:) }
it "computes the amount" do
result = subject
expect(result.format).to eq("$105.46")
end
end
end
23 changes: 23 additions & 0 deletions spec/lib/rides/commands/get_commute_duration_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# frozen_string_literal: true

RSpec.describe Rides::Commands::GetCommuteDuration do
it "gets data for an address" do
VCR.use_cassette("first_commute") do
from_address = create(
:address, :with_out_place_id, line_1: "4705 Weitzel Street", city: "Timnath", state: "CO",
zip_code: "80547"
)
to_address = create(
:address, :with_out_place_id, line_1: "151 N College Ave", city: "Fort Collins", state: "CO",
zip_code: "80524"
)
create_list(:ride, 2, from_address:, to_address:)
driver = create(:driver, current_address: from_address)
rides = Ride.selectable
data = described_class.call(rides:, driver:)

expect(data.length).to eq(2)
expect(data.all? { _1.duration == "577s" })
end
end
end
22 changes: 22 additions & 0 deletions spec/lib/rides/commands/get_routes_data_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# frozen_string_literal: true

RSpec.describe Rides::Commands::GetRoutesData do
it "gets data for an address" do
VCR.use_cassette("first_ride_directions") do
from_address = create(
:address, :with_out_place_id, line_1: "711 Oval Drive", city: "Fort Collins", state: "CO",
zip_code: "80521"
)
to_address = create(
:address, :with_out_place_id, line_1: "151 N College Ave", city: "Fort Collins", state: "CO",
zip_code: "80524"
)
create_list(:ride, 2, from_address:, to_address:)
rides = Ride.selectable
data = described_class.call(rides:)

expect(data.length).to eq(2)
expect(data.all? { _1.distance_in_meters == 3105 && _1.duration == "577s" })
end
end
end
Loading

0 comments on commit 710cd5a

Please sign in to comment.