From edf68846479c5f019310fb5735ee3febc2970648 Mon Sep 17 00:00:00 2001 From: Michael Bumann Date: Mon, 13 Apr 2020 22:59:46 +0200 Subject: [PATCH] Add support for Airtable --- Gemfile | 2 +- Gemfile.lock | 7 ++ app/models/form.rb | 82 ++++++++----------- app/models/submission.rb | 4 +- app/views/forms/index.html.erb | 2 +- app/views/forms/show.html.erb | 6 +- app/views/home/demo.html.erb | 4 +- config/application.rb | 11 +++ .../20200413152532_add_airtable_support.rb | 8 ++ db/schema.rb | 6 +- lib/spreadsheet_backends/airtable.rb | 36 ++++++++ lib/spreadsheet_backends/google_sheets.rb | 71 ++++++++++++++++ 12 files changed, 179 insertions(+), 60 deletions(-) create mode 100644 db/migrate/20200413152532_add_airtable_support.rb create mode 100644 lib/spreadsheet_backends/airtable.rb create mode 100644 lib/spreadsheet_backends/google_sheets.rb diff --git a/Gemfile b/Gemfile index 47338e4..e88578c 100644 --- a/Gemfile +++ b/Gemfile @@ -24,7 +24,7 @@ gem 'bootsnap', '>= 1.4.2', require: false gem 'lockbox' gem 'aws-sdk-s3', require: false -# gem 'airrecord' +gem 'airrecord' gem 'google-api-client' gem 'rack-cors' gem 'sentry-raven' diff --git a/Gemfile.lock b/Gemfile.lock index 90a1745..223b8fd 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -58,6 +58,9 @@ GEM zeitwerk (~> 2.2) addressable (2.7.0) public_suffix (>= 2.0.2, < 5.0) + airrecord (1.0.5) + faraday (>= 0.10, < 2.0) + net-http-persistent (>= 2.9) aws-eventstream (1.0.3) aws-partitions (1.263.0) aws-sdk-core (3.89.1) @@ -80,6 +83,7 @@ GEM builder (3.2.4) byebug (11.1.1) concurrent-ruby (1.1.6) + connection_pool (2.2.2) crass (1.0.6) declarative (0.0.10) declarative-option (0.1.0) @@ -136,6 +140,8 @@ GEM multi_json (1.14.1) multi_xml (0.6.0) multipart-post (2.1.1) + net-http-persistent (3.1.0) + connection_pool (~> 2.2) nio4r (2.5.2) nokogiri (1.10.9) mini_portile2 (~> 2.4.0) @@ -252,6 +258,7 @@ PLATFORMS ruby DEPENDENCIES + airrecord aws-sdk-s3 bootsnap (>= 1.4.2) byebug diff --git a/app/models/form.rb b/app/models/form.rb index b4fa31b..b4bcd37 100644 --- a/app/models/form.rb +++ b/app/models/form.rb @@ -1,15 +1,21 @@ -require 'google/apis/sheets_v4' class Form < ApplicationRecord - # Hash to translate an index to a A1 notation. e.g. 1 => 'B', 27 => 'AA' - COLUMN_INDEX_TO_LETTER = Hash.new {|hash,key| hash[key] = hash[key - 1].next }.merge({0 => "A"}) - belongs_to :user has_many :submissions, dependent: :destroy + + before_validation :insert_defaults, on: :create after_create :create_spreadsheet has_secure_token + encrypts :airtable_api_key + encrypts :airtable_app_key + validates_presence_of :title + validates_inclusion_of :backend_name, in: ['google_sheets', 'airtable'] + # Airtable validations + validates_presence_of :airtable_api_key, if: :airtable? + validates_presence_of :airtable_app_key, if: :airtable? + validates_presence_of :airtable_table, if: :airtable? # TODO: use counter_cache option on association def submissions_count @@ -28,63 +34,39 @@ class Form < ApplicationRecord self.user.active? end - def google_spreadsheet_url - "https://docs.google.com/spreadsheets/d/#{google_spreadsheet_id}/edit" if google_spreadsheet_id.present? + def airtable? + backend_name == 'airtable' + end + + def google + backend_name == 'google' + end + + def backend + @backend ||= SpreadsheetBackends.const_get(backend_name.camelize).new(self) + end + + def spreadsheet_url + backend.url end def create_spreadsheet - sheets = Google::Apis::SheetsV4::SheetsService.new - sheets.authorization = user.google_authorization - create_object = Google::Apis::SheetsV4::Spreadsheet.new(properties: { title: title}) - spreadsheet = sheets.create_spreadsheet(create_object) - update(google_spreadsheet_id: spreadsheet.spreadsheet_id) + backend.create end - def spreadsheet_service - @spreadsheet_service ||= Google::Apis::SheetsV4::SheetsService.new.tap do |s| - s.authorization = user.google_authorization - end + def append_to_spreadsheet(data) + backend.append(data) end - def header_values - @header_values ||= begin - values = spreadsheet_service.get_spreadsheet_values(google_spreadsheet_id, 'A1:An').values - # if there are no headers yet, return an empty array - if values - values[0].map(&:strip) - else - [] - end - end - end - - def append(data) - data = data.with_indifferent_access - check_spreadsheed_headers!(data) - - values = header_values.map { |key| data[key] } - range = "A1:A#{COLUMN_INDEX_TO_LETTER[values.length]}1" - value_range = Google::Apis::SheetsV4::ValueRange.new(values: [values], major_dimension: 'ROWS') - - spreadsheet_service.append_spreadsheet_value(google_spreadsheet_id, range, value_range, value_input_option: 'USER_ENTERED') - end - - def check_spreadsheed_headers!(data) - missing_headers = data.keys.map { |k| k.to_s.strip } - header_values - append_missing_headers(missing_headers) unless missing_headers.empty? - end - - def append_missing_headers(missing_headers) - start_column = COLUMN_INDEX_TO_LETTER[header_values.length] - end_column = COLUMN_INDEX_TO_LETTER[header_values.length + missing_headers.length] - range = "#{start_column}1:#{end_column}1" - value_range = Google::Apis::SheetsV4::ValueRange.new(values: [missing_headers], major_dimension: 'ROWS') - spreadsheet_service.update_spreadsheet_value(google_spreadsheet_id, range, value_range, value_input_option: 'USER_ENTERED') - @header_values = nil # reset header values to refresh memoization on next access + def spreadsheet_headers + backend.headers end def to_param token end + def insert_defaults + self.backend_name ||= airtable_app_key.present? ? 'airtable' : 'google_sheets' + end end diff --git a/app/models/submission.rb b/app/models/submission.rb index 4dfc874..3a60e14 100644 --- a/app/models/submission.rb +++ b/app/models/submission.rb @@ -44,7 +44,7 @@ class Submission < ApplicationRecord end def append_to_spreadsheet - result = form.append(data) - update_column(:appended_at, Time.current) if result.updates.updated_rows > 0 + form.append_to_spreadsheet(data) && + update_column(:appended_at, Time.current) end end diff --git a/app/views/forms/index.html.erb b/app/views/forms/index.html.erb index 261e4f7..5a9e61a 100644 --- a/app/views/forms/index.html.erb +++ b/app/views/forms/index.html.erb @@ -25,7 +25,7 @@
- <%= link_to form.google_spreadsheet_url, {class: "has-text-success", target: "__blank"} do %> + <%= link_to form.spreadsheet_url, {class: "has-text-success", target: "__blank"} do %> <% end %>
diff --git a/app/views/forms/show.html.erb b/app/views/forms/show.html.erb index c6679b7..5842b30 100644 --- a/app/views/forms/show.html.erb +++ b/app/views/forms/show.html.erb @@ -3,12 +3,12 @@ <%= submission_url(@form) %>

- <%= link_to 'Google spreadsheet', @form.google_spreadsheet_url %> + <%= link_to 'Google spreadsheet', @form.spreadsheet_url %>

- <% @form.header_values.each do |value| %> + <% @form.spreadsheet_headers.each do |value| %> <% end %> @@ -16,7 +16,7 @@ <% @submissions.each do |submission| %> - <% @form.header_values.each do |column| %> + <% @form.spreadsheet_headers.each do |column| %> <% end %> diff --git a/app/views/home/demo.html.erb b/app/views/home/demo.html.erb index c810740..c75d4bd 100644 --- a/app/views/home/demo.html.erb +++ b/app/views/home/demo.html.erb @@ -6,7 +6,7 @@

Demo

- This short form is connected to the embedded <%= link_to 'document', DEMO_FORM.google_spreadsheet_url %> on the right.
+ This short form is connected to the embedded <%= link_to 'document', DEMO_FORM.spreadsheet_url %> on the right.
Submit the form and see the update of the document in realtime.

@@ -80,7 +80,7 @@
-

Document not loading? <%= link_to 'Open it here', DEMO_FORM.google_spreadsheet_url %>.

+

Document not loading? <%= link_to 'Open it here', DEMO_FORM.spreadsheet_url %>.

diff --git a/config/application.rb b/config/application.rb index 2dae959..ffe45c8 100644 --- a/config/application.rb +++ b/config/application.rb @@ -15,6 +15,17 @@ module Tinyform # Application configuration can go into files in config/initializers # -- all .rb files in that directory are automatically loaded after loading # the framework and any gems in your application. + config.generators do |generate| + generate.helper false + generate.javascript_engine false + generate.request_specs false + generate.routing_specs false + generate.stylesheets false + generate.view_specs false + end + + config.autoload_paths << Rails.root.join('lib') + config.middleware.insert_before 0, Rack::Cors do allow do origins '*' diff --git a/db/migrate/20200413152532_add_airtable_support.rb b/db/migrate/20200413152532_add_airtable_support.rb new file mode 100644 index 0000000..a5465dc --- /dev/null +++ b/db/migrate/20200413152532_add_airtable_support.rb @@ -0,0 +1,8 @@ +class AddAirtableSupport < ActiveRecord::Migration[6.0] + def change + add_column :forms, :airtable_app_key_ciphertext, :string + add_column :forms, :airtable_api_key_ciphertext, :string + add_column :forms, :airtable_table, :string + add_column :forms, :backend_name, :string + end +end diff --git a/db/schema.rb b/db/schema.rb index 153d317..0c9af99 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 2020_04_13_101920) do +ActiveRecord::Schema.define(version: 2020_04_13_152532) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -57,6 +57,10 @@ ActiveRecord::Schema.define(version: 2020_04_13_101920) do t.string "thank_you_url" t.datetime "created_at", precision: 6, null: false t.datetime "updated_at", precision: 6, null: false + t.string "airtable_app_key_ciphertext" + t.string "airtable_api_key_ciphertext" + t.string "airtable_table" + t.string "backend_name" end create_table "submissions", force: :cascade do |t| diff --git a/lib/spreadsheet_backends/airtable.rb b/lib/spreadsheet_backends/airtable.rb new file mode 100644 index 0000000..047a8f8 --- /dev/null +++ b/lib/spreadsheet_backends/airtable.rb @@ -0,0 +1,36 @@ +require 'airrecord' +module SpreadsheetBackends + class Airtable + + attr_accessor :form, :user + + def initialize(form) + @form = form + @user = form.user + end + + def url + "https://airtable.com/#{form.airtable_app_key}" + end + + def append(data) + result = table.create(data, typecast: true) + result.id.present? + rescue Airrecord::Error => e + return false + end + + def create + # Airtable must already exist + # TODO: maybe add validation here? + end + + def headers + table.records.first&.fields&.keys # we only know the headers once we have at least one record + end + + def table + @table ||= Airrecord.table(form.airtable_api_key, form.airtable_app_key, form.airtable_table) + end + end +end diff --git a/lib/spreadsheet_backends/google_sheets.rb b/lib/spreadsheet_backends/google_sheets.rb new file mode 100644 index 0000000..d2a6ab3 --- /dev/null +++ b/lib/spreadsheet_backends/google_sheets.rb @@ -0,0 +1,71 @@ +require 'google/apis/sheets_v4' +module SpreadsheetBackends + class GoogleSheets + # Hash to translate an index to a A1 notation. e.g. 1 => 'B', 27 => 'AA' + COLUMN_INDEX_TO_LETTER = Hash.new {|hash,key| hash[key] = hash[key - 1].next }.merge({0 => "A"}) + + attr_accessor :form, :user + + def initialize(form) + @form = form + @user = form.user + end + + def url + "https://docs.google.com/spreadsheets/d/#{form.google_spreadsheet_id}/edit" if form.google_spreadsheet_id.present? + end + + def append(data) + data = data.with_indifferent_access + check_spreadsheed_headers!(data) + + values = headers.map { |key| data[key] } + range = "A1:A#{COLUMN_INDEX_TO_LETTER[values.length]}1" + value_range = Google::Apis::SheetsV4::ValueRange.new(values: [values], major_dimension: 'ROWS') + + result = spreadsheet_service.append_spreadsheet_value(form.google_spreadsheet_id, range, value_range, value_input_option: 'USER_ENTERED') + result.updates.updated_rows > 0 + end + + def create + sheets = Google::Apis::SheetsV4::SheetsService.new + sheets.authorization = user.google_authorization + create_object = Google::Apis::SheetsV4::Spreadsheet.new(properties: { title: form.title}) + spreadsheet = sheets.create_spreadsheet(create_object) + form.update(google_spreadsheet_id: spreadsheet.spreadsheet_id) + end + + def headers + @headers ||= begin + values = spreadsheet_service.get_spreadsheet_values(form.google_spreadsheet_id, 'A1:An').values + # if there are no headers yet, return an empty array + if values + values[0].map(&:strip) + else + [] + end + end + end + + def spreadsheet_service + @spreadsheet_service ||= Google::Apis::SheetsV4::SheetsService.new.tap do |s| + s.authorization = user.google_authorization + end + end + + def check_spreadsheed_headers!(data) + missing_headers = data.keys.map { |k| k.to_s.strip } - headers + append_missing_headers(missing_headers) unless missing_headers.empty? + end + + def append_missing_headers(missing_headers) + start_column = COLUMN_INDEX_TO_LETTER[headers.length] + end_column = COLUMN_INDEX_TO_LETTER[headers.length + missing_headers.length] + range = "#{start_column}1:#{end_column}1" + value_range = Google::Apis::SheetsV4::ValueRange.new(values: [missing_headers], major_dimension: 'ROWS') + spreadsheet_service.update_spreadsheet_value(form.google_spreadsheet_id, range, value_range, value_input_option: 'USER_ENTERED') + @headers = nil # reset header values to refresh memoization on next access + end + + end +end
<%= value %>
<%= submission.data[column] %>