Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
26220d0
Update database configuration to use environment variable for test da…
fabionl Jun 28, 2025
4ba00ac
Add VancouverApiClient and supporting classes for Vancouver Open Data…
fabionl Jun 28, 2025
f174232
Merge branch 'develop' into FL-VancouverApiClient
fabionl Jun 28, 2025
f863f68
Merge branch 'develop' into FL-VancouverApiClient
fabionl Jun 28, 2025
3cd0f5f
Fix indentation for form_with in facilities index view
fabionl Jun 28, 2025
62e5e78
Refactor services structure for external services.
fabionl Jun 29, 2025
22de355
Refactor API handling in ToolsController and add External::ApiHelper …
fabionl Jun 29, 2025
e5b75fe
Merge branch 'develop' into FL-VancouverApiClient
fabionl Jun 29, 2025
a98d941
cleanup
fabionl Jun 29, 2025
41de2e2
Refactor FacilityBuilder
fabionl Jun 30, 2025
bf92976
Update facility verification status to true for new imports
fabionl Jun 30, 2025
9b7827a
Add external_id field to Facility model and update FacilityBuilder to…
fabionl Jun 30, 2025
42736df
Refactor FacilityBuilder to streamline facility data extraction and e…
fabionl Jun 30, 2025
0505603
Fix facility building spec
fabionl Jun 30, 2025
e0382c4
Refactor FacilityBuilder geometry validation and update specs to chec…
fabionl Jun 30, 2025
f2eef7c
Rename LIMIT to PAGE_SIZE for consistency in Syncer class and update …
fabionl Jun 30, 2025
dd584b1
Add integration and unit tests for Vancouver City FacilitySyncer
fabionl Jul 1, 2025
e110f34
Refactor ResultData structure in FacilityBuilder and FacilitySyncer f…
fabionl Jul 1, 2025
c5d08ca
Add progress indicator and loading state for facility import form
fabionl Jul 1, 2025
58c9f51
Add service key mapping and update related specs for Vancouver City A…
fabionl Jul 1, 2025
8aa37df
Add API key validation and remove redundant tests in FacilityServiceB…
fabionl Jul 1, 2025
89ee891
Update service parameter naming in facilities index
fabionl Jul 1, 2025
3aa0807
Fix redirect path for successful facility import in ToolsController
fabionl Jul 1, 2025
4831744
Fix FacilitySyncer specs
fabionl Jul 1, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -119,3 +119,6 @@ gem "cssbundling-rails", "~> 1.4"
# Aborts requests that are taking too long.
# Set the timeout by setting the RACK_TIMEOUT_SERVICE_TIMEOUT env var
# gem "rack-timeout"

# Http client for making API requests
gem "faraday", "~> 2.13.1"
11 changes: 11 additions & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,12 @@ GEM
railties (>= 5.0.0)
faker (3.4.2)
i18n (>= 1.8.11, < 2)
faraday (2.13.1)
faraday-net_http (>= 2.0, < 3.5)
json
logger
faraday-net_http (3.4.1)
net-http (>= 0.5.0)
ffi (1.16.3)
geo_coord (0.2.0)
geocoder (1.8.2)
Expand Down Expand Up @@ -163,6 +169,7 @@ GEM
listen (3.9.0)
rb-fsevent (~> 0.10, >= 0.10.3)
rb-inotify (~> 0.9, >= 0.9.10)
logger (1.7.0)
loofah (2.22.0)
crass (~> 1.0.2)
nokogiri (>= 1.12.0)
Expand All @@ -178,6 +185,8 @@ GEM
mini_mime (1.1.5)
minitest (5.25.1)
msgpack (1.7.2)
net-http (0.6.0)
uri
net-imap (0.4.10)
date
net-protocol
Expand Down Expand Up @@ -349,6 +358,7 @@ GEM
tzinfo (2.0.6)
concurrent-ruby (~> 1.0)
unicode-display_width (2.5.0)
uri (1.0.3)
view_component (3.11.0)
activesupport (>= 5.2.0, < 8.0)
concurrent-ruby (~> 1.0)
Expand Down Expand Up @@ -387,6 +397,7 @@ DEPENDENCIES
dotenv-rails
factory_bot_rails (~> 6.4.3)
faker (~> 3.4.2)
faraday (~> 2.13.1)
geo_coord
geocoder (~> 1.8)
haversine!
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,12 @@

<div class="navbar-end">
<% if helpers.user_signed_in? %>
<% if helpers.current_user.admin? %>
<div class="navbar-item">
<%= link_to 'Tools', admin_tools_path, class: 'button is-light' %>
</div>
<% end %>

<div class="navbar-item">
<%= link_to admin_user_path(current_user), class: 'button is-light' do %>
<span>Logged in as <strong><%= helpers.current_user.email %></strong></span>
Expand Down
9 changes: 4 additions & 5 deletions app/controllers/admin/facilities_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -85,11 +85,10 @@ def load_facilities
facilities = facilities.discarded
end

if params[:service_id] == "none"
if params[:service] == "none"
facilities = facilities.without_services
elsif params[:service_id].present?
facilities = facilities.joins(:services)
.where(services: { id: params[:service_id] })
elsif params[:service].present?
facilities = facilities.with_service(params[:service])
end

if params[:welcome_customer] == "none"
Expand All @@ -115,7 +114,7 @@ def load_facility
end

def load_services_dropdown
@services_dropdown = [["No Services", :none]] + Service.pluck(:name, :id)
@services_dropdown = [["No Services", :none]] + Service.pluck(:name, :key)
end

def load_welcomes_dropdown
Expand Down
45 changes: 45 additions & 0 deletions app/controllers/admin/tools_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# frozen_string_literal: true

class Admin::ToolsController < Admin::BaseController
before_action :enforce_admin_user

def index

end

def import_facilities
api_key = params[:api]

# Validate that both parameters are present and supported
unless External::ApiHelper.supported_api?(api_key)
redirect_to admin_tools_path, alert: "Invalid API selected. Please choose from the supported APIs."
return
end

result = External::VancouverCity::Syncer.call(
api_key: api_key,
api_client: External::VancouverCity.default_client
)

if result.success?
total_count = result.data[:total_count] || 0
redirect_to admin_facilities_path(service: "water_fountain"), notice: "#{total_count} Facilities imported successfully from #{External::ApiHelper.api_name(api_key)}."
else
error_messages = result.errors.join(', ')
redirect_to admin_tools_path, alert: "Failed to import facilities: #{error_messages}"
end
end

# Helper method for the view
helper_method :api_options_for_select

private

def api_options_for_select
External::ApiHelper.api_options
end

def enforce_admin_user
redirect_to root_path, alert: "Access denied! You must be an admin to access tools" unless current_user&.admin?
end
end
6 changes: 6 additions & 0 deletions app/models/facility.rb
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ class Facility < ApplicationRecord
scope :with_service, ->(service_key_or_name) { joins(:services).where(services: Service.exact_search(service_key_or_name)) }
scope :without_services, -> { where.not(facility_services: FacilityService.all) }
scope :without_welcomes, -> { where.not(facility_welcomes: FacilityWelcome.all) }
scope :external, -> { where.not(external_id: nil) }
scope :not_external, -> { where(external_id: nil) }

def managed_by?(user)
f_user_id = if user.respond_to? :id
Expand Down Expand Up @@ -68,6 +70,10 @@ def self.statuses
%i[live pending_reviews discarded]
end

def external?
external_id.present?
end

def status
if discarded?
:discarded
Expand Down
51 changes: 51 additions & 0 deletions app/services/external/api_helper.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# frozen_string_literal: true

# Configuration class for supported Vancouver City APIs and facility services
class External::ApiHelper
# Available Vancouver City Facilities APIs
# Each API represents a different type of facility data available from Vancouver's Open Data portal
SUPPORTED_APIS = {
'drinking-fountains' => 'Drinking Fountains'
}.freeze

# Mapping of dataset IDs to service keys
# This mapping is used to associate API keys with specific service types in the system
DATASET_ID_TO_SERVICE_KEY = {
'drinking-fountains' => 'water_fountain'
}.freeze

class << self
# Get all supported API options for select fields
# @return [Array<Array>] Array of [display_name, api_key] pairs
def api_options
SUPPORTED_APIS.map { |key, name| [name, key] }
end

# Get all supported API keys
# @return [Array<String>] Array of API keys
def supported_api_keys
SUPPORTED_APIS.keys
end

# Check if an API is supported
# @param api_key [String] The API key to check
# @return [Boolean] True if the API is supported
def supported_api?(api_key)
SUPPORTED_APIS.key?(api_key.to_s)
end

# Get the service key for a given API key
# @param api_key [String] The API key to find the service key for
# @return [String, nil] The service key or nil if not found
def service_key_for(api_key)
DATASET_ID_TO_SERVICE_KEY.dig(api_key.to_s)
end

# Get the display name for an API
# @param api_key [String] The API key
# @return [String, nil] The display name or nil if not found
def api_name(api_key)
SUPPORTED_APIS[api_key.to_s]
end
end
end
9 changes: 9 additions & 0 deletions app/services/external/vancouver_city.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# frozen_string_literal: true

# Namespace for Vancouver City API integration services
module External::VancouverCity
# Convenience method to get default API client
def self.default_client
VancouverApiClient.default_client
end
end
146 changes: 146 additions & 0 deletions app/services/external/vancouver_city/adapters/faraday_adapter.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
# frozen_string_literal: true

require 'faraday'

module External::VancouverCity
module Adapters
# Faraday HTTP adapter for the Vancouver API client
# Uses the builder pattern for flexible configuration
class FaradayAdapter
attr_reader :connection

def initialize(connection)
@connection = connection
end

def self.create(vancouver_api_config)
builder(vancouver_api_config.base_url)
.timeout(vancouver_api_config.timeout)
.open_timeout(vancouver_api_config.open_timeout)
.build
end

# Builder class for creating configured Faraday connections
class Builder
DEFAULT_TIMEOUT = 30
DEFAULT_OPEN_TIMEOUT = 10
DEFAULT_USER_AGENT = 'Linkvan API Client'

def initialize(base_url)
@base_url = base_url
@timeout = DEFAULT_TIMEOUT
@open_timeout = DEFAULT_OPEN_TIMEOUT
@user_agent = DEFAULT_USER_AGENT
@headers = {}
@adapter = Faraday.default_adapter
end
# Set request timeout
# @param timeout [Integer] Request timeout in seconds
# @return [Builder] self for method chaining
def timeout(timeout)
@timeout = timeout
self
end

# Set connection timeout
# @param open_timeout [Integer] Connection timeout in seconds
# @return [Builder] self for method chaining
def open_timeout(open_timeout)
@open_timeout = open_timeout
self
end

# Set user agent string
# @param user_agent [String] User agent for requests
# @return [Builder] self for method chaining
def user_agent(user_agent)
@user_agent = user_agent
self
end

# Add custom header
# @param name [String] Header name
# @param value [String] Header value
# @return [Builder] self for method chaining
def header(name, value)
@headers[name] = value
self
end

# Set Faraday adapter
# @param adapter [Symbol, Object] Faraday adapter
# @return [Builder] self for method chaining
def adapter(adapter)
@adapter = adapter
self
end

# Build the configured Faraday connection
# @return [FaradayAdapter] Configured adapter instance
def build
connection = Faraday.new(url: @base_url) do |config|
config.adapter @adapter

# Set timeouts
config.options.timeout = @timeout
config.options.open_timeout = @open_timeout

# Set default headers
config.headers['User-Agent'] = @user_agent
config.headers['Accept'] = 'application/json'

# Add custom headers
@headers.each do |name, value|
config.headers[name] = value
end
end

FaradayAdapter.new(connection)
end
end

# Create a new builder for the given base URL
# @param base_url [String] The base URL for the API
# @return [Builder] A new builder instance
def self.builder(base_url)
Builder.new(base_url)
end

# Delegate HTTP methods to the Faraday connection
def get(path, params = {})
@connection.get(path, params)
end

def post(path, body = nil, params = {})
@connection.post(path, body, params)
end

def put(path, body = nil, params = {})
@connection.put(path, body, params)
end

def delete(path, params = {})
@connection.delete(path, params)
end

def patch(path, body = nil, params = {})
@connection.patch(path, body, params)
end

# Access connection options for testing
def options
@connection.options
end

# Access connection headers for testing
def headers
@connection.headers
end

# Access connection URL prefix for testing
def url_prefix
@connection.url_prefix
end
end
end
end
Loading