Skip to content

Commit

Permalink
Basic functionality for airtable staff list
Browse files Browse the repository at this point in the history
These classes work together to download data from airtable and compile
a CSV staff list. It is not yet a "job", but if you have an airtable
personal access token with the appropriate permissions, the following
should work in rails c:

AirTableStaff::CSVBuilder.new.to_csv

Co-authored-by: Christina Chortaria <[email protected]>
Co-authored-by: Kevin Reiss <[email protected]>
Co-authored-by: Max Kadel <[email protected]>
Co-authored-by: regineheberlein <[email protected]>
Co-authored-by: Ryan Laddusaw <[email protected]>
Co-authored-by: Vivian Ha <[email protected]>
Co-authored-by: Winsice Ng <[email protected]>
  • Loading branch information
8 people committed Jun 21, 2024
1 parent 2a14dde commit 12dfd32
Show file tree
Hide file tree
Showing 12 changed files with 351 additions and 0 deletions.
19 changes: 19 additions & 0 deletions app/models/air_table_staff/csv_builder.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# frozen_string_literal: true

module AirTableStaff
# This class is responsible for creating a CSV out of the
# data from Airtable
class CSVBuilder
def to_csv
@csv ||= CSV.generate do |csv|
# Add the headers...
csv << StaffDirectoryMapping.new.to_a

# Then add the data
AirTableStaff::RecordList.new.to_a.each do |record|
csv << record.to_a
end
end
end
end
end
22 changes: 22 additions & 0 deletions app/models/air_table_staff/json_value_extractor.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# frozen_string_literal: true
module AirTableStaff
# This class is responsible for extracting a single
# value from a json hash, based on the criteria in
# the field hash
class JsonValueExtractor
def initialize(json:, field:)
@json = json
@field = field
end

def extract
raw_value = json[field[:airtable_field]]
transformer = field[:transformer]
transformer ? transformer.call(raw_value) : raw_value
end

private

attr_reader :json, :field
end
end
51 changes: 51 additions & 0 deletions app/models/air_table_staff/record_list.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# frozen_string_literal: true
module AirTableStaff
# This class is responsible for maintaining a list
# of staff records taken from the Airtable API
class RecordList
def initialize
@base_url = "https://api.airtable.com/v0/appv7XA5FWS7DG9oe/Synchronized%20Staff%20Directory%20View?view=Grid%20view"
@token = LibJobs.config[:airtable_token]
end

# The library staff list is split into several pages.
# For each page (except the last), Airtable gives us an
# offset, which is how we request the next page.
def to_a(offset: nil)
@as_array ||= begin
json = get_json(offset:)
records = json[:records].map do |row|
AirTableStaff::StaffDirectoryPerson.new(row[:fields])
end
offset = json[:offset]

# If we have an offset, call this method recursively
# (to fetch additional pages of data), until airtable
# no longer gives us an offset
records += to_a(offset:) if offset
records
end
end

private

attr_reader :base_url, :token

def get_json(offset: nil)
JSON.parse(response(offset:).body, symbolize_names: true)
end

def response(offset: nil)
url = url_with_optional_offset(offset:)
request = Net::HTTP::Get.new(url)
request["Authorization"] = "Bearer #{token}"
Net::HTTP.start(url.hostname, url.port, { use_ssl: true }) do |http|
http.request(request)
end
end

def url_with_optional_offset(offset: nil)
URI.parse(offset ? "#{base_url}&offset=#{offset}" : base_url)
end
end
end
36 changes: 36 additions & 0 deletions app/models/air_table_staff/staff_directory_mapping.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# frozen_string_literal: true
module AirTableStaff
# This class is responsible for describing the fields in the
# airtable and the resultant CSV, and how they are related
class StaffDirectoryMapping
# A mapping of:
# - an airtable field (airtable_field)
# - a column name we want for our csv (our_field)
# - an optional "transformer" lambda for converting the airtable values
# to the desired format for the csv
# rubocop:disable Metrics/MethodLength
def fields
[
{ airtable_field: :'University ID', our_field: :puid },
{ airtable_field: :netid, our_field: :netid },
{ airtable_field: :'University Phone', our_field: :phone },
{ airtable_field: :'pul:Preferred Name', our_field: :name },
{ airtable_field: :'Last Name', our_field: :lastName },
{ airtable_field: :'First Name', our_field: :firstName },
{ airtable_field: :Email, our_field: :email },
{ airtable_field: :Address, our_field: :address },
{ airtable_field: :'pul:Building', our_field: :building },
{ airtable_field: :Division, our_field: :department },
{ airtable_field: :'pul:Department', our_field: :division },
{ airtable_field: :'pul:Unit', our_field: :unit },
{ airtable_field: :'pul:Team', our_field: :team },
{ airtable_field: :'Area of Study', our_field: :areasOfStudy, transformer: ->(areas) { areas&.join('//') } }
]
end
# rubocop:enable Metrics/MethodLength

def to_a
@as_array ||= fields.pluck(:our_field)
end
end
end
22 changes: 22 additions & 0 deletions app/models/air_table_staff/staff_directory_person.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# frozen_string_literal: true
module AirTableStaff
# This class is responsible for extracting information about
# a person from the airtable staff directory JSON, according
# to the mapping from the StaffDirectoryMapping class.
class StaffDirectoryPerson
def initialize(json)
@json = json
@mapping = StaffDirectoryMapping.new
end

def to_a
@array_version ||= mapping.fields.map do |field|
JsonValueExtractor.new(field:, json:).extract
end
end

private

attr_reader :json, :mapping
end
end
1 change: 1 addition & 0 deletions config/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ defaults: &defaults
alma_region: <%= ENV["ALMA_REGION"] %>
alma_api_key: <%= ENV["ALMA_CONFIG_API_KEY"] || "1234" %>
alma_bib_api_key: <%= ENV["ALMA_BIB_API_KEY"] || "1234" %>
airtable_token: <%= ENV["AIRTABLE_TOKEN"] || 'FAKE_AIRTABLE_TOKEN' %>
development:
<<: *defaults

Expand Down
21 changes: 21 additions & 0 deletions spec/fixtures/files/air_table/records_no_offset.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
{
"records": [
{
"id": "recrhIUhcJw3lUlRE",
"createdTime": "2024-01-30T22:16:16.000Z",
"fields": {
"Address": "123 Stokes",
"Area of Study": ["Virtual Reality"],
"Division": "Stokes",
"pul:Building": "Stokes",
"Last Name": "Librarian",
"First Name": "Phillip",
"University ID": "123",
"netid": "ab123",
"University Phone": "(123) 123-1234",
"pul:Preferred Name": "Phillip Librarian",
"Email": "[email protected]"
}
}
]
}
21 changes: 21 additions & 0 deletions spec/fixtures/files/air_table/records_with_offset.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
{
"records": [
{
"id": "recrhIUhcJw3lUlRE",
"createdTime": "2025-01-30T22:16:16.000Z",
"fields": {
"Address": "Firestone A floor",
"Area of Study": ["Cinema history", "Robots"],
"Division": "Special Collections",
"pul:Building": "Firestone",
"Last Name": "Carmant",
"First Name": "Drema",
"University ID": "456",
"netid": "zz99",
"University Phone": "(123) 555-5555",
"pul:Preferred Name": "Drema Carmant",
"Email": "[email protected]"
}
}
], "offset": "naeQu2ul/Ash6eiQu"
}
22 changes: 22 additions & 0 deletions spec/models/air_table_staff/csv_builder_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# frozen_string_literal: true
require 'rails_helper'

RSpec.describe AirTableStaff::CSVBuilder do
before do
stub_request(:get, "https://api.airtable.com/v0/appv7XA5FWS7DG9oe/Synchronized%20Staff%20Directory%20View?view=Grid%20view")
.with(
headers: {
'Authorization' => 'Bearer FAKE_AIRTABLE_TOKEN'
}
)
.to_return(status: 200, body: File.read(file_fixture('air_table/records_no_offset.json')), headers: {})
end
it 'creates a CSV object with data from the HTTP API' do
expected = <<~END_CSV
puid,netid,phone,name,lastName,firstName,email,address,building,department,division,unit,team,areasOfStudy
123,ab123,(123) 123-1234,Phillip Librarian,Librarian,Phillip,[email protected],123 Stokes,Stokes,Stokes,,,,Virtual Reality
END_CSV
directory = described_class.new
expect(directory.to_csv).to eq(expected)
end
end
19 changes: 19 additions & 0 deletions spec/models/air_table_staff/json_value_extractor_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# frozen_string_literal: true
require 'rails_helper'

RSpec.describe AirTableStaff::JsonValueExtractor do
context 'when there is no transformer lambda provided in field config' do
it 'extracts the text verbatim' do
json = { cat: 'tabby cat' }
field = { airtable_field: :cat }
expect(described_class.new(json:, field:).extract).to eq('tabby cat')
end
end
context 'when there is a transformer lambda provided in field config' do
it 'extracts the text verbatim' do
json = { cat: 'tabby cat' }
field = { airtable_field: :cat, transformer: ->(value) { "My favorite cat is #{value}" } }
expect(described_class.new(json:, field:).extract).to eq('My favorite cat is tabby cat')
end
end
end
74 changes: 74 additions & 0 deletions spec/models/air_table_staff/record_list_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
# frozen_string_literal: true
require 'rails_helper'

BASE_AIRTABLE_URL = 'https://api.airtable.com/v0/appv7XA5FWS7DG9oe/Synchronized%20Staff%20Directory%20View?view=Grid%20view'

RSpec.describe AirTableStaff::RecordList do
context 'when the airtable response is not paginated' do
before do
stub_request(:get, BASE_AIRTABLE_URL)
.with(
headers: {
'Authorization' => 'Bearer FAKE_AIRTABLE_TOKEN'
}
)
.to_return(status: 200, body: File.read(file_fixture('air_table/records_no_offset.json')), headers: {})
end
it 'creates an array with data from a single call to the HTTP API' do
list = described_class.new.to_a

expect(list.length).to eq(1)

first_person = list[0].to_a
expect(first_person[0]).to eq('123') # puid
expect(first_person[3]).to eq('Phillip Librarian') # name

expect(WebMock).to have_requested(:get, BASE_AIRTABLE_URL).once
end
end
context 'when the airtable response is paginated' do
before do
stub_request(:get, BASE_AIRTABLE_URL)
.with(
headers: {
'Authorization' => 'Bearer FAKE_AIRTABLE_TOKEN'
}
)
.to_return(status: 200, body: File.read(file_fixture('air_table/records_with_offset.json')), headers: {})
stub_request(:get, "#{BASE_AIRTABLE_URL}&offset=naeQu2ul/Ash6eiQu")
.with(
headers: {
'Authorization' => 'Bearer FAKE_AIRTABLE_TOKEN'
}
)
.to_return(status: 200, body: File.read(file_fixture('air_table/records_no_offset.json')), headers: {})
end
it 'creates an array with data from multiple calls to the HTTP API' do
list = described_class.new.to_a

expect(list.length).to eq(2)

first_person = list[0].to_a
expect(first_person[0]).to eq('456') # puid
expect(first_person[3]).to eq('Drema Carmant') # name

second_person = list[1].to_a
expect(second_person[0]).to eq('123') # puid
expect(second_person[3]).to eq('Phillip Librarian') # name

expect(WebMock).to have_requested(:get, BASE_AIRTABLE_URL).once
expect(WebMock).to have_requested(:get, "#{BASE_AIRTABLE_URL}&offset=naeQu2ul/Ash6eiQu").once
end
context 'when we run it multiple times' do
it 'gives us the same data without additional network calls' do
list = described_class.new
first_time = list.to_a
second_time = list.to_a

expect(first_time).to eq(second_time)
expect(WebMock).to have_requested(:get, BASE_AIRTABLE_URL).once
expect(WebMock).to have_requested(:get, "#{BASE_AIRTABLE_URL}&offset=naeQu2ul/Ash6eiQu").once
end
end
end
end
43 changes: 43 additions & 0 deletions spec/models/air_table_staff/staff_directory_person_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# frozen_string_literal: true
require 'rails_helper'

RSpec.describe AirTableStaff::StaffDirectoryPerson do
describe '#to_a' do
it 'uses the order from the mapping' do
json = {
'pul:Preferred Name': 'Sage Archivist',
'University ID': '987654321',
'netid': 'ab1234',
'University Phone': '(609) 555-1234',
'Last Name': 'Archivist',
'First Name': 'Phoenix',
'Email': '[email protected]',
'Address': '123 Lewis Library',
'pul:Building': 'Stokes Library',
'Division': 'ReCAP',
'pul:Department': 'Cataloging and Metadata Services',
'pul:Unit': 'Rare Books Cataloging Team',
'pul:Team': 'IT, Discovery and Access Services',
'Area of Study': ['Chemistry', 'African American Studies']
}
expected = [
'987654321', # puid
'ab1234', # netid
'(609) 555-1234', # phone
'Sage Archivist', # name
'Archivist', # lastName
'Phoenix', # firstName
'[email protected]', # email
'123 Lewis Library', # address
'Stokes Library', # building
'ReCAP', # department
'Cataloging and Metadata Services', # division
'Rare Books Cataloging Team', # unit
'IT, Discovery and Access Services', # team
'Chemistry//African American Studies' # areasOfStudy
]

expect(described_class.new(json).to_a).to eq(expected)
end
end
end

0 comments on commit 12dfd32

Please sign in to comment.