diff --git a/.gitignore b/.gitignore
index d5f0944..85716eb 100644
--- a/.gitignore
+++ b/.gitignore
@@ -33,7 +33,11 @@
# Ignore config files
/config/bibdata.yml
+/config/database.yml
/config/folio.yml
+/config/redis.yml
+/config/resque.yml
+config/permissions.yml
# macOS Files
.DS_Store
diff --git a/Capfile b/Capfile
index 86840b5..978c072 100644
--- a/Capfile
+++ b/Capfile
@@ -12,7 +12,7 @@ install_plugin Capistrano::SCM::Git
# Include tasks from other gems included in your Gemfile
require 'capistrano/cul'
require 'capistrano/bundler'
-# require "capistrano/rails/migrations"
+require 'capistrano/rails/migrations'
require 'capistrano/passenger'
# Load custom tasks from `lib/capistrano/tasks` if you have any defined
diff --git a/Gemfile b/Gemfile
index 14ad921..8e65b56 100644
--- a/Gemfile
+++ b/Gemfile
@@ -6,8 +6,9 @@ gem "rails", "~> 8.0.2"
gem "puma", ">= 5.0"
# Build JSON APIs with ease [https://github.com/rails/jbuilder]
# gem "jbuilder"
-# Use Redis adapter to run Action Cable in production
-# gem "redis", ">= 4.0.1"
+
+# For ActiveRecord, mysql for deployed server databases
+gem 'mysql2', '~> 0.5.6'
# Use Active Model has_secure_password [https://guides.rubyonrails.org/active_model_basics.html#securepassword]
# gem "bcrypt", "~> 3.1.7"
@@ -38,6 +39,18 @@ gem "rainbow", "~> 3.0"
# Retriable for retrying operations in response to specific exceptions
gem 'retriable', '~> 3.1'
+# Resque/Redis (for queued jobs)
+gem 'redis', '~> 4.8' # NOTE: Updating the redis gem to v5 breaks the current redis namespace setup
+gem 'redis-namespace', '~> 1.11'
+gem 'resque', '~> 2.6'
+
+# For authentication
+gem 'devise', '~> 4.9'
+gem 'omniauth', '~> 2.1'
+gem 'omniauth-rails_csrf_protection', '~> 1.0'
+# gem 'omniauth-cul', '~> 0.2.0'
+gem 'omniauth-cul', git: 'https://github.com/cul/omniauth-cul', ref: 'improved-implementation'
+
# FOLIO Client
gem "folio_api_client", "~> 0.4.3"
# gem "folio_api_client", path: "../folio_api_client"
@@ -55,6 +68,9 @@ group :development, :test do
gem "rspec-rails", "~> 8.0"
gem "simplecov", require: false
+
+ # For ActiveRecord, use sqlite for dev/test databases and mysql for deployed server databases
+ gem 'sqlite3', '~> 2.8'
end
group :development do
diff --git a/Gemfile.lock b/Gemfile.lock
index f09bd6a..87de729 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -1,3 +1,12 @@
+GIT
+ remote: https://github.com/cul/omniauth-cul
+ revision: 116607e324e109dd335a409596d81d8b377fcbf5
+ ref: improved-implementation
+ specs:
+ omniauth-cul (0.2.0)
+ devise (>= 4.9)
+ omniauth (>= 2.0)
+
GEM
remote: https://rubygems.org/
specs:
@@ -76,6 +85,7 @@ GEM
sshkit (>= 1.6.1, != 1.7.0)
ast (2.4.3)
base64 (0.3.0)
+ bcrypt (3.1.20)
bcrypt_pbkdf (1.1.1)
bcrypt_pbkdf (1.1.1-arm64-darwin)
bcrypt_pbkdf (1.1.1-x86_64-darwin)
@@ -113,6 +123,12 @@ GEM
debug (1.11.0)
irb (~> 1.10)
reline (>= 0.3.8)
+ devise (4.9.4)
+ bcrypt (~> 3.0)
+ orm_adapter (~> 0.1)
+ railties (>= 4.1.0)
+ responders
+ warden (~> 1.2.3)
diff-lcs (1.6.2)
docile (1.4.1)
dotenv (3.1.8)
@@ -132,6 +148,7 @@ GEM
zeitwerk (~> 2.7)
globalid (1.3.0)
activesupport (>= 6.1)
+ hashie (5.0.0)
i18n (1.14.7)
concurrent-ruby (~> 1.0)
io-console (0.8.1)
@@ -168,7 +185,13 @@ GEM
mini_mime (1.1.5)
mini_portile2 (2.8.9)
minitest (5.25.5)
+ mono_logger (1.1.2)
msgpack (1.8.0)
+ multi_json (1.17.0)
+ mustermann (3.0.4)
+ ruby2_keywords (~> 0.0.1)
+ mysql2 (0.5.7)
+ bigdecimal
net-http (0.6.0)
uri
net-imap (0.5.12)
@@ -189,6 +212,15 @@ GEM
nokogiri (1.18.10)
mini_portile2 (~> 2.8.2)
racc (~> 1.4)
+ omniauth (2.1.4)
+ hashie (>= 3.4.6)
+ logger
+ rack (>= 2.2.3)
+ rack-protection
+ omniauth-rails_csrf_protection (1.0.2)
+ actionpack (>= 4.2)
+ omniauth (~> 2.0)
+ orm_adapter (0.5.0)
ostruct (0.6.3)
parallel (1.27.0)
parser (3.3.9.0)
@@ -205,6 +237,10 @@ GEM
nio4r (~> 2.0)
racc (1.8.1)
rack (3.2.3)
+ rack-protection (4.2.1)
+ base64 (>= 0.1.0)
+ logger (>= 1.6.0)
+ rack (>= 3.0.0, < 4)
rack-session (2.1.1)
base64 (>= 0.1.0)
rack (>= 3.0.0)
@@ -248,9 +284,20 @@ GEM
erb
psych (>= 4.0.0)
tsort
+ redis (4.8.1)
+ redis-namespace (1.11.0)
+ redis (>= 4)
regexp_parser (2.11.2)
reline (0.6.2)
io-console (~> 0.5)
+ responders (3.2.0)
+ actionpack (>= 7.0)
+ railties (>= 7.0)
+ resque (2.7.0)
+ mono_logger (~> 1)
+ multi_json (~> 1.0)
+ redis-namespace (~> 1.6)
+ sinatra (>= 0.9.2)
retriable (3.1.2)
rexml (3.4.4)
rspec-core (3.13.5)
@@ -315,6 +362,7 @@ GEM
rubocop-rails (~> 2.25)
rubocop-rspec (~> 2.28)
ruby-progressbar (1.13.0)
+ ruby2_keywords (0.0.5)
securerandom (0.4.1)
simplecov (0.22.0)
docile (~> 1.1)
@@ -322,6 +370,21 @@ GEM
simplecov_json_formatter (~> 0.1)
simplecov-html (0.13.1)
simplecov_json_formatter (0.1.4)
+ sinatra (4.2.1)
+ logger (>= 1.6.0)
+ mustermann (~> 3.0)
+ rack (>= 3.0.0, < 4)
+ rack-protection (= 4.2.1)
+ rack-session (>= 2.0.0, < 3)
+ tilt (~> 2.0)
+ sqlite3 (2.8.0-aarch64-linux-gnu)
+ sqlite3 (2.8.0-aarch64-linux-musl)
+ sqlite3 (2.8.0-arm-linux-gnu)
+ sqlite3 (2.8.0-arm-linux-musl)
+ sqlite3 (2.8.0-arm64-darwin)
+ sqlite3 (2.8.0-x86_64-darwin)
+ sqlite3 (2.8.0-x86_64-linux-gnu)
+ sqlite3 (2.8.0-x86_64-linux-musl)
sshkit (1.24.0)
base64
logger
@@ -336,6 +399,7 @@ GEM
thruster (0.1.15-arm64-darwin)
thruster (0.1.15-x86_64-darwin)
thruster (0.1.15-x86_64-linux)
+ tilt (2.6.1)
timeout (0.4.3)
tsort (0.2.0)
tzinfo (2.0.6)
@@ -343,6 +407,8 @@ GEM
unicode-display_width (2.6.0)
uri (1.0.4)
useragent (0.16.11)
+ warden (1.2.9)
+ rack (>= 2.0.9)
websocket-driver (0.8.0)
base64
websocket-extensions (>= 0.1.0)
@@ -369,17 +435,26 @@ DEPENDENCIES
capistrano-passenger (~> 0.1)
capistrano-rails (~> 1.4)
debug
+ devise (~> 4.9)
folio_api_client (~> 0.4.3)
kamal
marc (~> 1.3)
+ mysql2 (~> 0.5.6)
nokogiri (~> 1.18, >= 1.18.10)
+ omniauth (~> 2.1)
+ omniauth-cul!
+ omniauth-rails_csrf_protection (~> 1.0)
puma (>= 5.0)
rails (~> 8.0.2)
rainbow (~> 3.0)
+ redis (~> 4.8)
+ redis-namespace (~> 1.11)
+ resque (~> 2.6)
retriable (~> 3.1)
rspec-rails (~> 8.0)
rubocul (~> 4.0.12)
simplecov
+ sqlite3 (~> 2.8)
thruster
tzinfo-data
diff --git a/README.md b/README.md
index c0d0770..54a67ca 100644
--- a/README.md
+++ b/README.md
@@ -9,4 +9,10 @@ bundle install
# Copy config template files
./bin/copy-config-template-files
+
+# Start Redis with docker (in a separate terminal window)
+docker compose up
+
+# Start the rails server
+rails s
```
diff --git a/app/controllers/api_controller.rb b/app/controllers/api_controller.rb
new file mode 100644
index 0000000..8c4a55a
--- /dev/null
+++ b/app/controllers/api_controller.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+class ApiController < ActionController::API
+ # Unlike ActionController::Base, ActionController::API does not include token HttpAuthentication Token
+ # methods by default, so we'll include it.
+ include ActionController::HttpAuthentication::Token::ControllerMethods
+end
diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb
index 14b3fe1..7944f9f 100644
--- a/app/controllers/application_controller.rb
+++ b/app/controllers/application_controller.rb
@@ -1,11 +1,4 @@
# frozen_string_literal: true
-class ApplicationController < ActionController::API
- # Unlike ActionController::Base, ActionController::API does not include token HttpAuthentication Token
- # methods by default, so we'll include it.
- include ActionController::HttpAuthentication::Token::ControllerMethods
-
- def index
- render plain: "#{Rails.application.class.module_parent_name}\nVersion: #{VERSION}"
- end
+class ApplicationController < ActionController::Base
end
diff --git a/app/controllers/pages_controller.rb b/app/controllers/pages_controller.rb
new file mode 100644
index 0000000..b7ab4ce
--- /dev/null
+++ b/app/controllers/pages_controller.rb
@@ -0,0 +1,5 @@
+# frozen_string_literal: true
+
+class PagesController < ApplicationController
+ def home; end
+end
diff --git a/app/controllers/users/omniauth_callbacks_controller.rb b/app/controllers/users/omniauth_callbacks_controller.rb
new file mode 100644
index 0000000..e8d1b6f
--- /dev/null
+++ b/app/controllers/users/omniauth_callbacks_controller.rb
@@ -0,0 +1,54 @@
+# frozen_string_literal: true
+
+require 'omniauth/cul'
+
+class Users::OmniauthCallbacksController < Devise::OmniauthCallbacksController
+ # See https://github.com/omniauth/omniauth/wiki/FAQ#rails-session-is-clobbered-after-callback-on-developer-strategy
+ # The CAS login redirect to the columbia_cas callback endpoint AND the developer form submission to the
+ # developer_uid callback do not send authenticity tokens, so we'll skip token verification for these actions.
+ skip_before_action :verify_authenticity_token, only: [:columbia_cas, :developer_uid]
+
+ # POST /users/auth/developer_uid/callback
+ def developer_uid
+ return unless Rails.env.development? # Only allow this action to run in the development environment
+
+ uid = params[:uid]
+ user = User.find_by(uid: uid) || User.create!(
+ uid: uid,
+ email: "#{uid}@library.columbia.edu"
+ )
+
+ unless user
+ flash[:alert] = "Login attempt failed. User #{uid} does not have an account."
+ redirect_to root_path
+ return
+ end
+
+ sign_in_and_redirect user, event: :authentication # this will throw if user is not activated
+ end
+
+ # POST /users/auth/columbia_cas/callback
+ def columbia_cas # rubocop:disable Metrics/AbcSize,Metrics/MethodLength
+ callback_url = user_columbia_cas_omniauth_callback_url # The columbia_cas callback route in this application
+ uid, affils = Omniauth::Cul::ColumbiaCas.validation_callback(request.params['ticket'], callback_url)
+
+ if Omniauth::Cul::PermissionFileValidator.permitted?(uid, affils)
+ user = User.find_by(uid: uid) || User.create!(
+ uid: uid,
+ email: "#{uid}@columbia.edu"
+ )
+ sign_in_and_redirect user, event: :authentication # this will throw if user is not activated
+ else
+ flash[:alert] = 'Login attempt failed'
+ redirect_to root_path
+ end
+ rescue Omniauth::Cul::Exceptions::Error => e
+ # If an unexpected CAS ticket validation occurs, log the error message and ask the user to try
+ # logging in again. Do not display the exception object's original message to the user because it may
+ # contain information that only a developer should see.
+ error_message = 'CAS login validation failed. Please try again.'
+ Rails.logger.debug(error_message + " #{e.class.name}: #{e.message}")
+ flash[:alert] = error_message
+ redirect_to root_path
+ end
+end
diff --git a/app/controllers/users/sessions_controller.rb b/app/controllers/users/sessions_controller.rb
new file mode 100644
index 0000000..9dd0ca5
--- /dev/null
+++ b/app/controllers/users/sessions_controller.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+# rubocop:disable Lint/UselessMethodDefinition
+
+class Users::SessionsController < Devise::SessionsController
+ # GET /users/sign_in
+ def new
+ super
+ end
+
+ # DELETE /users/sign_out
+ def destroy
+ super
+ end
+end
+
+# rubocop:enable Lint/UselessMethodDefinition
diff --git a/app/models/application_record.rb b/app/models/application_record.rb
new file mode 100644
index 0000000..71fbba5
--- /dev/null
+++ b/app/models/application_record.rb
@@ -0,0 +1,5 @@
+# frozen_string_literal: true
+
+class ApplicationRecord < ActiveRecord::Base
+ self.abstract_class = true
+end
diff --git a/app/models/user.rb b/app/models/user.rb
new file mode 100644
index 0000000..82ce61d
--- /dev/null
+++ b/app/models/user.rb
@@ -0,0 +1,5 @@
+# frozen_string_literal: true
+
+class User < ApplicationRecord
+ devise :omniauthable, omniauth_providers: Devise.omniauth_configs.keys
+end
diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb
new file mode 100644
index 0000000..b96da5c
--- /dev/null
+++ b/app/views/layouts/application.html.erb
@@ -0,0 +1,17 @@
+
+
+
+ Triclops
+ <%= csrf_meta_tags %>
+ <%= csp_meta_tag %>
+
+ <%= stylesheet_link_tag 'application', media: 'all' %>
+
+
+
+ <% flash.each do |type, msg| %>
+ <%= msg %>
+ <% end %>
+ <%= yield %>
+
+
diff --git a/app/views/pages/home.html.erb b/app/views/pages/home.html.erb
new file mode 100644
index 0000000..42c536e
--- /dev/null
+++ b/app/views/pages/home.html.erb
@@ -0,0 +1,30 @@
+<%= Rails.application.name.capitalize %>
+Version: <%= ::VERSION %>
+
+<% if current_user.present? %>
+ You are signed in as: <%= current_user.uid %>
+ View Resque status
+ <% # The sign out route uses a DELETE method to prevent CSRF attacks. %>
+
+ <%= form_with(url: users_sign_out_path, method: "delete") do |form| %>
+ <%= form.submit "Sign out" %>
+ <% end %>
+
+<% else %>
+ Sign In
+
+ <% if Devise.mappings[:user].omniauthable? %>
+ <% User.omniauth_providers.each do |provider| %>
+
+
<%=
+ button_to(
+ "Sign in with #{Devise.omniauth_configs.fetch(provider, nil)&.options&.fetch(:label, nil) || provider.name.to_s.titleize}",
+ omniauth_authorize_path(:user, provider),
+ :class => 'btn btn-primary btn-sm btn-block')
+ %>
+
+ <% end %>
+ <% end %>
+<% end %>
+
+
diff --git a/config/application.rb b/config/application.rb
index 606ed57..bc9a4cf 100644
--- a/config/application.rb
+++ b/config/application.rb
@@ -6,15 +6,15 @@
# Pick the frameworks you want:
require 'active_model/railtie'
require 'active_job/railtie'
-# require "active_record/railtie"
+require 'active_record/railtie'
# require "active_storage/engine"
require 'action_controller/railtie'
require 'action_mailer/railtie'
# require "action_mailbox/engine"
# require "action_text/engine"
require 'action_view/railtie'
-require 'action_cable/engine'
-require 'rails/test_unit/railtie'
+# require "action_cable/engine"
+# require "rails/test_unit/railtie"
# Require the gems listed in Gemfile, including any gems
# you've limited to :test, :development, or :production.
@@ -25,6 +25,10 @@ class Application < Rails::Application
# Initialize configuration defaults for originally generated Rails version.
config.load_defaults 8.0
+ config.generators do |g|
+ g.test_framework :rspec, spec: true
+ end
+
# Please, add to the `ignore` list any other `lib` subdirectories that do
# not contain `.rb` files, or that should not be reloaded or eager loaded.
# Common ones are `templates`, `generators`, or `middleware`, for example.
@@ -38,13 +42,13 @@ class Application < Rails::Application
# config.time_zone = "Central Time (US & Canada)"
# config.eager_load_paths << Rails.root.join("extras")
- # Only loads a smaller set of middleware suitable for API only apps.
- # Middleware like session, flash, cookies can be added back manually.
- # Skip views, helpers and assets when generating a new resource.
- config.api_only = true
-
# Load custom configs
config.bibdata = Rails.application.config_for(:bibdata)
config.folio = config_for(:folio)
+
+ # Use Resque for ActiveJob
+ config.active_job.queue_adapter = :resque
+ config.active_job.queue_name_prefix = "bibdata.#{Rails.env}"
+ config.active_job.queue_name_delimiter = '.'
end
end
diff --git a/config/deploy.rb b/config/deploy.rb
index baf1b62..b4a5115 100644
--- a/config/deploy.rb
+++ b/config/deploy.rb
@@ -22,9 +22,12 @@
# Default value for :linked_files is []
append :linked_files,
- # 'config/database.yml',
+ 'config/database.yml',
'config/bibdata.yml',
'config/folio.yml',
+ 'config/redis.yml',
+ 'config/resque.yml',
+ 'config/permissions.yml',
'config/master.key' # we don't use this often, when we do it is for API keys
# Default value for linked_dirs is []
@@ -85,3 +88,23 @@
# Uncomment the following to require manually verifying the host key before first deploy.
# set :ssh_options, verify_host_key: :secure
+
+after 'deploy:finished', 'bibdata:restart_resque_workers'
+
+namespace :bibdata do
+ desc 'Restart the resque workers'
+ task :restart_resque_workers do
+ on roles(:web) do
+ within release_path do
+ with rails_env: fetch(:rails_env) do
+ resque_restart_err_and_out_log = './log/resque_restart_err_and_out.log'
+ # With Ruby > 3.0, we need to redirect stdout and stderr to a file, otherwise
+ # capistrano hangs on this task (waiting for more output).
+ execute :rake, 'resque:restart_workers', '>', resque_restart_err_and_out_log, '2>&1'
+ # Show the restart log output
+ execute :cat, resque_restart_err_and_out_log
+ end
+ end
+ end
+ end
+end
diff --git a/config/environments/development.rb b/config/environments/development.rb
index a1de5bd..a3b9cca 100644
--- a/config/environments/development.rb
+++ b/config/environments/development.rb
@@ -67,4 +67,7 @@
# Apply autocorrection by RuboCop to files generated by `bin/rails generate`.
# config.generators.apply_rubocop_autocorrect_after_generate!
+
+ # Allow local CAS testing via hyacinth-dev domain
+ config.hosts << 'bibdata-dev.cul.columbia.edu'
end
diff --git a/config/initializers/devise.rb b/config/initializers/devise.rb
new file mode 100644
index 0000000..f75dab1
--- /dev/null
+++ b/config/initializers/devise.rb
@@ -0,0 +1,315 @@
+# frozen_string_literal: true
+
+# Assuming you have not yet modified this file, each configuration option below
+# is set to its default value. Note that some are commented out while others
+# are not: uncommented lines are intended to protect your configuration from
+# breaking changes in upgrades (i.e., in the event that future versions of
+# Devise change the default values for those options).
+#
+# Use this hook to configure devise mailer, warden hooks and so forth.
+# Many of these configuration options can be set straight in your model.
+Devise.setup do |config|
+ # The secret key used by Devise. Devise uses this key to generate
+ # random tokens. Changing this key will render invalid all existing
+ # confirmation, reset password and unlock tokens in the database.
+ # Devise will use the `secret_key_base` as its `secret_key`
+ # by default. You can change it below and use your own secret key.
+ # config.secret_key = '1cc4057a8e6c06885d8a5eb538ae7fd731f0a0f9a0a5f1302de5f0c39144fc14abe5581d216f9a562557e8873514253a776e19474d71b5a1e888c0b133a371cc' # rubocop:disable Layout/LineLength
+
+ # ==> Controller configuration
+ # Configure the parent class to the devise controllers.
+ # config.parent_controller = 'DeviseController'
+
+ # ==> Mailer Configuration
+ # Configure the e-mail address which will be shown in Devise::Mailer,
+ # note that it will be overwritten if you use your own mailer class
+ # with default "from" parameter.
+ config.mailer_sender = 'bibdata-no-reply@library.columbia.edu'
+
+ # Configure the class responsible to send e-mails.
+ # config.mailer = 'Devise::Mailer'
+
+ # Configure the parent class responsible to send e-mails.
+ # config.parent_mailer = 'ActionMailer::Base'
+
+ # ==> ORM configuration
+ # Load and configure the ORM. Supports :active_record (default) and
+ # :mongoid (bson_ext recommended) by default. Other ORMs may be
+ # available as additional gems.
+ require 'devise/orm/active_record'
+
+ # ==> Configuration for any authentication mechanism
+ # Configure which keys are used when authenticating a user. The default is
+ # just :email. You can configure it to use [:username, :subdomain], so for
+ # authenticating a user, both parameters are required. Remember that those
+ # parameters are used only when authenticating and not when retrieving from
+ # session. If you need permissions, you should implement that in a before filter.
+ # You can also supply a hash where the value is a boolean determining whether
+ # or not authentication should be aborted when the value is not present.
+ # config.authentication_keys = [:email]
+
+ # Configure parameters from the request object used for authentication. Each entry
+ # given should be a request method and it will automatically be passed to the
+ # find_for_authentication method and considered in your model lookup. For instance,
+ # if you set :request_keys to [:subdomain], :subdomain will be used on authentication.
+ # The same considerations mentioned for authentication_keys also apply to request_keys.
+ # config.request_keys = []
+
+ # Configure which authentication keys should be case-insensitive.
+ # These keys will be downcased upon creating or modifying a user and when used
+ # to authenticate or find a user. Default is :email.
+ config.case_insensitive_keys = [:email]
+
+ # Configure which authentication keys should have whitespace stripped.
+ # These keys will have whitespace before and after removed upon creating or
+ # modifying a user and when used to authenticate or find a user. Default is :email.
+ config.strip_whitespace_keys = [:email]
+
+ # Tell if authentication through request.params is enabled. True by default.
+ # It can be set to an array that will enable params authentication only for the
+ # given strategies, for example, `config.params_authenticatable = [:database]` will
+ # enable it only for database (email + password) authentication.
+ # config.params_authenticatable = true
+
+ # Tell if authentication through HTTP Auth is enabled. False by default.
+ # It can be set to an array that will enable http authentication only for the
+ # given strategies, for example, `config.http_authenticatable = [:database]` will
+ # enable it only for database authentication.
+ # For API-only applications to support authentication "out-of-the-box", you will likely want to
+ # enable this with :database unless you are using a custom strategy.
+ # The supported strategies are:
+ # :database = Support basic authentication with authentication key + password
+ # config.http_authenticatable = false
+
+ # If 401 status code should be returned for AJAX requests. True by default.
+ # config.http_authenticatable_on_xhr = true
+
+ # The realm used in Http Basic Authentication. 'Application' by default.
+ # config.http_authentication_realm = 'Application'
+
+ # It will change confirmation, password recovery and other workflows
+ # to behave the same regardless if the e-mail provided was right or wrong.
+ # Does not affect registerable.
+ # config.paranoid = true
+
+ # By default Devise will store the user in session. You can skip storage for
+ # particular strategies by setting this option.
+ # Notice that if you are skipping storage for all authentication paths, you
+ # may want to disable generating routes to Devise's sessions controller by
+ # passing skip: :sessions to `devise_for` in your config/routes.rb
+ config.skip_session_storage = [:http_auth]
+
+ # By default, Devise cleans up the CSRF token on authentication to
+ # avoid CSRF token fixation attacks. This means that, when using AJAX
+ # requests for sign in and sign up, you need to get a new CSRF token
+ # from the server. You can disable this option at your own risk.
+ # config.clean_up_csrf_token_on_authentication = true
+
+ # When false, Devise will not attempt to reload routes on eager load.
+ # This can reduce the time taken to boot the app but if your application
+ # requires the Devise mappings to be loaded during boot time the application
+ # won't boot properly.
+ # config.reload_routes = true
+
+ # ==> Configuration for :database_authenticatable
+ # For bcrypt, this is the cost for hashing the password and defaults to 12. If
+ # using other algorithms, it sets how many times you want the password to be hashed.
+ # The number of stretches used for generating the hashed password are stored
+ # with the hashed password. This allows you to change the stretches without
+ # invalidating existing passwords.
+ #
+ # Limiting the stretches to just one in testing will increase the performance of
+ # your test suite dramatically. However, it is STRONGLY RECOMMENDED to not use
+ # a value less than 10 in other environments. Note that, for bcrypt (the default
+ # algorithm), the cost increases exponentially with the number of stretches (e.g.
+ # a value of 20 is already extremely slow: approx. 60 seconds for 1 calculation).
+ config.stretches = Rails.env.test? ? 1 : 12
+
+ # Set up a pepper to generate the hashed password.
+ # config.pepper = '2b940068f1392e0ebc33158361234f9475e6cf7d74f2bb429b0961617db39d57d609e7a4e6f89670e9b5d24ad6ae8eb059ab7fd1d6f63ae1805ee4d20e01312a' # rubocop:disable Layout/LineLength
+
+ # Send a notification to the original email when the user's email is changed.
+ # config.send_email_changed_notification = false
+
+ # Send a notification email when the user's password is changed.
+ # config.send_password_change_notification = false
+
+ # ==> Configuration for :confirmable
+ # A period that the user is allowed to access the website even without
+ # confirming their account. For instance, if set to 2.days, the user will be
+ # able to access the website for two days without confirming their account,
+ # access will be blocked just in the third day.
+ # You can also set it to nil, which will allow the user to access the website
+ # without confirming their account.
+ # Default is 0.days, meaning the user cannot access the website without
+ # confirming their account.
+ # config.allow_unconfirmed_access_for = 2.days
+
+ # A period that the user is allowed to confirm their account before their
+ # token becomes invalid. For example, if set to 3.days, the user can confirm
+ # their account within 3 days after the mail was sent, but on the fourth day
+ # their account can't be confirmed with the token any more.
+ # Default is nil, meaning there is no restriction on how long a user can take
+ # before confirming their account.
+ # config.confirm_within = 3.days
+
+ # If true, requires any email changes to be confirmed (exactly the same way as
+ # initial account confirmation) to be applied. Requires additional unconfirmed_email
+ # db field (see migrations). Until confirmed, new email is stored in
+ # unconfirmed_email column, and copied to email column on successful confirmation.
+ config.reconfirmable = true
+
+ # Defines which key will be used when confirming an account
+ # config.confirmation_keys = [:email]
+
+ # ==> Configuration for :rememberable
+ # The time the user will be remembered without asking for credentials again.
+ # config.remember_for = 2.weeks
+
+ # Invalidates all the remember me tokens when the user signs out.
+ config.expire_all_remember_me_on_sign_out = true
+
+ # If true, extends the user's remember period when remembered via cookie.
+ # config.extend_remember_period = false
+
+ # Options to be passed to the created cookie. For instance, you can set
+ # secure: true in order to force SSL only cookies.
+ # config.rememberable_options = {}
+
+ # ==> Configuration for :validatable
+ # Range for password length.
+ config.password_length = 6..128
+
+ # Email regex used to validate email formats. It simply asserts that
+ # one (and only one) @ exists in the given string. This is mainly
+ # to give user feedback and not to assert the e-mail validity.
+ config.email_regexp = /\A[^@\s]+@[^@\s]+\z/
+
+ # ==> Configuration for :timeoutable
+ # The time you want to timeout the user session without activity. After this
+ # time the user will be asked for credentials again. Default is 30 minutes.
+ # config.timeout_in = 30.minutes
+
+ # ==> Configuration for :lockable
+ # Defines which strategy will be used to lock an account.
+ # :failed_attempts = Locks an account after a number of failed attempts to sign in.
+ # :none = No lock strategy. You should handle locking by yourself.
+ # config.lock_strategy = :failed_attempts
+
+ # Defines which key will be used when locking and unlocking an account
+ # config.unlock_keys = [:email]
+
+ # Defines which strategy will be used to unlock an account.
+ # :email = Sends an unlock link to the user email
+ # :time = Re-enables login after a certain amount of time (see :unlock_in below)
+ # :both = Enables both strategies
+ # :none = No unlock strategy. You should handle unlocking by yourself.
+ # config.unlock_strategy = :both
+
+ # Number of authentication tries before locking an account if lock_strategy
+ # is failed attempts.
+ # config.maximum_attempts = 20
+
+ # Time interval to unlock the account if :time is enabled as unlock_strategy.
+ # config.unlock_in = 1.hour
+
+ # Warn on the last attempt before the account is locked.
+ # config.last_attempt_warning = true
+
+ # ==> Configuration for :recoverable
+ #
+ # Defines which key will be used when recovering the password for an account
+ # config.reset_password_keys = [:email]
+
+ # Time interval you can reset your password with a reset password key.
+ # Don't put a too small interval or your users won't have the time to
+ # change their passwords.
+ config.reset_password_within = 6.hours
+
+ # When set to false, does not sign a user in automatically after their password is
+ # reset. Defaults to true, so a user is signed in automatically after a reset.
+ # config.sign_in_after_reset_password = true
+
+ # ==> Configuration for :encryptable
+ # Allow you to use another hashing or encryption algorithm besides bcrypt (default).
+ # You can use :sha1, :sha512 or algorithms from others authentication tools as
+ # :clearance_sha1, :authlogic_sha512 (then you should set stretches above to 20
+ # for default behavior) and :restful_authentication_sha1 (then you should set
+ # stretches to 10, and copy REST_AUTH_SITE_KEY to pepper).
+ #
+ # Require the `devise-encryptable` gem when using anything other than bcrypt
+ # config.encryptor = :sha512
+
+ # ==> Scopes configuration
+ # Turn scoped views on. Before rendering "sessions/new", it will first check for
+ # "users/sessions/new". It's turned off by default because it's slower if you
+ # are using only default views.
+ # config.scoped_views = false
+
+ # Configure the default scope given to Warden. By default it's the first
+ # devise role declared in your routes (usually :user).
+ # config.default_scope = :user
+
+ # Set this configuration to false if you want /users/sign_out to sign out
+ # only the current scope. By default, Devise signs out all scopes.
+ # config.sign_out_all_scopes = true
+
+ # ==> Navigation configuration
+ # Lists the formats that should be treated as navigational. Formats like
+ # :html should redirect to the sign in page when the user does not have
+ # access, but formats like :xml or :json, should return 401.
+ #
+ # If you have any extra navigational formats, like :iphone or :mobile, you
+ # should add them to the navigational formats lists.
+ #
+ # The "*/*" below is required to match Internet Explorer requests.
+ # config.navigational_formats = ['*/*', :html, :turbo_stream]
+
+ # The default HTTP method used to sign out a resource. Default is :delete.
+ config.sign_out_via = :delete
+
+ # ==> OmniAuth
+ # Add a new OmniAuth provider. Check the wiki for more information on setting
+ # up on your models and hooks.
+ # config.omniauth :github, 'APP_ID', 'APP_SECRET', scope: 'user,public_repo'
+ config.omniauth :columbia_cas, { label: 'Columbia SSO (CAS)' }
+ config.omniauth :developer_uid, { label: 'Developer UID' } if Rails.env.development?
+
+ # ==> Warden configuration
+ # If you want to use other strategies, that are not supported by Devise, or
+ # change the failure app, you can configure them inside the config.warden block.
+ #
+ # config.warden do |manager|
+ # manager.intercept_401 = false
+ # manager.default_strategies(scope: :user).unshift :some_external_strategy
+ # end
+
+ # ==> Mountable engine configurations
+ # When using Devise inside an engine, let's call it `MyEngine`, and this engine
+ # is mountable, there are some extra configurations to be taken into account.
+ # The following options are available, assuming the engine is mounted as:
+ #
+ # mount MyEngine, at: '/my_engine'
+ #
+ # The router that invoked `devise_for`, in the example above, would be:
+ # config.router_name = :my_engine
+ #
+ # When using OmniAuth, Devise cannot automatically set OmniAuth path,
+ # so you need to do it manually. For the users scope, it would be:
+ # config.omniauth_path_prefix = '/my_engine/users/auth'
+
+ # ==> Hotwire/Turbo configuration
+ # When using Devise with Hotwire/Turbo, the http status for error responses
+ # and some redirects must match the following. The default in Devise for existing
+ # apps is `200 OK` and `302 Found` respectively, but new apps are generated with
+ # these new defaults that match Hotwire/Turbo behavior.
+ # Note: These might become the new default in future versions of Devise.
+ config.responder.error_status = :unprocessable_entity
+ config.responder.redirect_status = :see_other
+
+ # ==> Configuration for :registerable
+
+ # When set to false, does not sign a user in automatically after their password is
+ # changed. Defaults to true, so a user is signed in automatically after changing a password.
+ # config.sign_in_after_change_password = true
+end
diff --git a/config/initializers/resque.rb b/config/initializers/resque.rb
new file mode 100644
index 0000000..5ed8506
--- /dev/null
+++ b/config/initializers/resque.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+# Set resque to log to a file
+Resque.logger = Logger.new(Rails.root.join('log', "#{Rails.env}_resque.log"))
+# Match the Rails logger level
+Resque.logger.level = Rails.logger.level
+
+redis_config = Rails.application.config_for(:redis)
+
+# Apply redis config to resque
+Resque.redis = redis_config
+# Set the namespace
+Resque.redis.namespace = "Resque:#{redis_config[:namespace]}"
diff --git a/config/locales/devise.en.yml b/config/locales/devise.en.yml
new file mode 100644
index 0000000..260e1c4
--- /dev/null
+++ b/config/locales/devise.en.yml
@@ -0,0 +1,65 @@
+# Additional translations at https://github.com/heartcombo/devise/wiki/I18n
+
+en:
+ devise:
+ confirmations:
+ confirmed: "Your email address has been successfully confirmed."
+ send_instructions: "You will receive an email with instructions for how to confirm your email address in a few minutes."
+ send_paranoid_instructions: "If your email address exists in our database, you will receive an email with instructions for how to confirm your email address in a few minutes."
+ failure:
+ already_authenticated: "You are already signed in."
+ inactive: "Your account is not activated yet."
+ invalid: "Invalid %{authentication_keys} or password."
+ locked: "Your account is locked."
+ last_attempt: "You have one more attempt before your account is locked."
+ not_found_in_database: "Invalid %{authentication_keys} or password."
+ timeout: "Your session expired. Please sign in again to continue."
+ unauthenticated: "You need to sign in or sign up before continuing."
+ unconfirmed: "You have to confirm your email address before continuing."
+ mailer:
+ confirmation_instructions:
+ subject: "Confirmation instructions"
+ reset_password_instructions:
+ subject: "Reset password instructions"
+ unlock_instructions:
+ subject: "Unlock instructions"
+ email_changed:
+ subject: "Email Changed"
+ password_change:
+ subject: "Password Changed"
+ omniauth_callbacks:
+ failure: "Could not authenticate you from %{kind} because \"%{reason}\"."
+ success: "Successfully authenticated from %{kind} account."
+ passwords:
+ no_token: "You can't access this page without coming from a password reset email. If you do come from a password reset email, please make sure you used the full URL provided."
+ send_instructions: "You will receive an email with instructions on how to reset your password in a few minutes."
+ send_paranoid_instructions: "If your email address exists in our database, you will receive a password recovery link at your email address in a few minutes."
+ updated: "Your password has been changed successfully. You are now signed in."
+ updated_not_active: "Your password has been changed successfully."
+ registrations:
+ destroyed: "Bye! Your account has been successfully cancelled. We hope to see you again soon."
+ signed_up: "Welcome! You have signed up successfully."
+ signed_up_but_inactive: "You have signed up successfully. However, we could not sign you in because your account is not yet activated."
+ signed_up_but_locked: "You have signed up successfully. However, we could not sign you in because your account is locked."
+ signed_up_but_unconfirmed: "A message with a confirmation link has been sent to your email address. Please follow the link to activate your account."
+ update_needs_confirmation: "You updated your account successfully, but we need to verify your new email address. Please check your email and follow the confirmation link to confirm your new email address."
+ updated: "Your account has been updated successfully."
+ updated_but_not_signed_in: "Your account has been updated successfully, but since your password was changed, you need to sign in again."
+ sessions:
+ signed_in: "Signed in successfully."
+ signed_out: "Signed out successfully."
+ already_signed_out: "Signed out successfully."
+ unlocks:
+ send_instructions: "You will receive an email with instructions for how to unlock your account in a few minutes."
+ send_paranoid_instructions: "If your account exists, you will receive an email with instructions for how to unlock it in a few minutes."
+ unlocked: "Your account has been unlocked successfully. Please sign in to continue."
+ errors:
+ messages:
+ already_confirmed: "was already confirmed, please try signing in"
+ confirmation_period_expired: "needs to be confirmed within %{period}, please request a new one"
+ expired: "has expired, please request a new one"
+ not_found: "not found"
+ not_locked: "was not locked"
+ not_saved:
+ one: "1 error prohibited this %{resource} from being saved:"
+ other: "%{count} errors prohibited this %{resource} from being saved:"
diff --git a/config/routes.rb b/config/routes.rb
index 6c04d1e..75996a7 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -1,6 +1,14 @@
# frozen_string_literal: true
+require 'resque/server'
+
Rails.application.routes.draw do
+ devise_for :users, controllers: { sessions: 'users/sessions', omniauth_callbacks: 'users/omniauth_callbacks' }
+ devise_scope :user do
+ get '/users/sign_in', to: 'users/sessions#new'
+ delete '/users/sign_out', to: 'users/sessions#destroy'
+ end
+
get 'barcode/:barcode/query' => 'barcode#query'
post 'barcode/:barcode/update' => 'barcode#update'
# Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html
@@ -10,5 +18,15 @@
get 'up' => 'rails/health#show', as: :rails_health_check
# Defines the root path route ("/")
- root 'application#index'
+ root 'pages#home'
+
+ # Make sure that the resque user restriction below is AFTER `devise_for :users`
+ resque_web_constraint = lambda do |request|
+ current_user = request.env['warden'].user
+ current_user.present? && current_user.respond_to?(:admin?) && current_user.admin?
+ true
+ end
+ constraints resque_web_constraint do
+ mount Resque::Server.new, at: '/resque'
+ end
end
diff --git a/config/templates/database.template.yml b/config/templates/database.template.yml
new file mode 100644
index 0000000..40892b0
--- /dev/null
+++ b/config/templates/database.template.yml
@@ -0,0 +1,15 @@
+default: &default
+ adapter: sqlite3
+ pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
+ timeout: 5000
+
+development:
+ <<: *default
+ database: storage/development.sqlite3
+
+# Warning: The database defined as "test" will be erased and
+# re-generated from your development database when you run "rake".
+# Do not set this db to the same as development or production.
+test:
+ <<: *default
+ database: storage/test.sqlite3
diff --git a/config/templates/permissions.template.yml b/config/templates/permissions.template.yml
new file mode 100644
index 0000000..e74ed70
--- /dev/null
+++ b/config/templates/permissions.template.yml
@@ -0,0 +1,11 @@
+# Permissions
+
+all: &all
+ allowed_user_ids:
+ - abcdef123
+ - ghijkl456
+ allowed_user_affils:
+ - Very_Important_Users
+
+development: *all
+test: *all
\ No newline at end of file
diff --git a/config/templates/redis.template.yml b/config/templates/redis.template.yml
new file mode 100644
index 0000000..002ecb6
--- /dev/null
+++ b/config/templates/redis.template.yml
@@ -0,0 +1,11 @@
+default: &default
+ host: localhost
+ port: 6379
+ password: null
+ namespace: <%= "#{Rails.application.class.module_parent_name}:#{Rails.env}" %>
+
+development:
+ <<: *default
+
+test:
+ <<: *default
diff --git a/config/templates/resque.template.yml b/config/templates/resque.template.yml
new file mode 100644
index 0000000..d640450
--- /dev/null
+++ b/config/templates/resque.template.yml
@@ -0,0 +1,18 @@
+default: &default
+ workers:
+ '*' : 1
+ polling_interval: 5
+
+development:
+ <<: *default
+ polling_interval: 3 # More frequent polling interval in development environment
+
+test:
+ <<: *default
+
+production:
+ <<: *default
+ workers:
+ '*' : 1
+ 'high' : 3
+ 'medium,low' : 1
diff --git a/db/migrate/20251126191128_devise_create_users.rb b/db/migrate/20251126191128_devise_create_users.rb
new file mode 100644
index 0000000..11535f3
--- /dev/null
+++ b/db/migrate/20251126191128_devise_create_users.rb
@@ -0,0 +1,43 @@
+# frozen_string_literal: true
+
+class DeviseCreateUsers < ActiveRecord::Migration[8.0]
+ def change
+ create_table :users do |t|
+ ## Database authenticatable
+ t.string :email, null: false, default: ""
+ # t.string :encrypted_password, null: false, default: ""
+
+ ## Recoverable
+ # t.string :reset_password_token
+ # t.datetime :reset_password_sent_at
+
+ ## Rememberable
+ # t.datetime :remember_created_at
+
+ ## Trackable
+ t.integer :sign_in_count, default: 0, null: false
+ t.datetime :current_sign_in_at
+ t.datetime :last_sign_in_at
+ t.string :current_sign_in_ip
+ t.string :last_sign_in_ip
+
+ ## Confirmable
+ # t.string :confirmation_token
+ # t.datetime :confirmed_at
+ # t.datetime :confirmation_sent_at
+ # t.string :unconfirmed_email # Only if using reconfirmable
+
+ ## Lockable
+ # t.integer :failed_attempts, default: 0, null: false # Only if lock strategy is :failed_attempts
+ # t.string :unlock_token # Only if unlock strategy is :email or :both
+ # t.datetime :locked_at
+
+ t.timestamps null: false
+ end
+
+ add_index :users, :email, unique: true
+ # add_index :users, :reset_password_token, unique: true
+ # add_index :users, :confirmation_token, unique: true
+ # add_index :users, :unlock_token, unique: true
+ end
+end
diff --git a/db/migrate/20251126191240_add_uid_to_users.rb b/db/migrate/20251126191240_add_uid_to_users.rb
new file mode 100644
index 0000000..9e1a123
--- /dev/null
+++ b/db/migrate/20251126191240_add_uid_to_users.rb
@@ -0,0 +1,6 @@
+class AddUidToUsers < ActiveRecord::Migration[8.0]
+ def change
+ add_column :users, :uid, :string
+ add_index :users, :uid, unique: true
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
new file mode 100644
index 0000000..c52501d
--- /dev/null
+++ b/db/schema.rb
@@ -0,0 +1,27 @@
+# This file is auto-generated from the current state of the database. Instead
+# of editing this file, please use the migrations feature of Active Record to
+# incrementally modify your database, and then regenerate this schema definition.
+#
+# This file is the source Rails uses to define your schema when running `bin/rails
+# db:schema:load`. When creating a new database, `bin/rails db:schema:load` tends to
+# be faster and is potentially less error prone than running all of your
+# migrations from scratch. Old migrations may fail to apply correctly if those
+# migrations use external dependencies or application code.
+#
+# It's strongly recommended that you check this file into your version control system.
+
+ActiveRecord::Schema[8.0].define(version: 2025_11_26_191240) do
+ create_table "users", force: :cascade do |t|
+ t.string "email", default: "", null: false
+ t.integer "sign_in_count", default: 0, null: false
+ t.datetime "current_sign_in_at"
+ t.datetime "last_sign_in_at"
+ t.string "current_sign_in_ip"
+ t.string "last_sign_in_ip"
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ t.string "uid"
+ t.index ["email"], name: "index_users_on_email", unique: true
+ t.index ["uid"], name: "index_users_on_uid", unique: true
+ end
+end
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..cb087c8
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,13 @@
+name: 'bibdata'
+
+volumes:
+ redis-data:
+
+services:
+ redis:
+ image: redis:7.0.2
+ ports:
+ - 6379:6379
+ volumes:
+ - redis-data:/data
+
diff --git a/lib/tasks/bibdata/ci.rake b/lib/tasks/bibdata/ci.rake
index 38ac880..5c9cfc0 100644
--- a/lib/tasks/bibdata/ci.rake
+++ b/lib/tasks/bibdata/ci.rake
@@ -32,10 +32,10 @@ namespace :bibdata do
Rails.env = ENV['RAILS_ENV']
# puts "setting up test db...\n"
- # Rake::Task["db:environment:set"].invoke
- # Rake::Task["db:drop"].invoke
- # Rake::Task["db:create"].invoke
- # Rake::Task["db:migrate"].invoke
+ Rake::Task['db:environment:set'].invoke
+ Rake::Task['db:drop'].invoke
+ Rake::Task['db:create'].invoke
+ Rake::Task['db:migrate'].invoke
begin
Rake::Task['bibdata:rspec'].invoke
rescue SystemExit => e
diff --git a/lib/tasks/resque.rake b/lib/tasks/resque.rake
new file mode 100644
index 0000000..70a5071
--- /dev/null
+++ b/lib/tasks/resque.rake
@@ -0,0 +1,124 @@
+# frozen_string_literal: true
+
+# rubocop:disable Metrics/AbcSize,Metrics/CyclomaticComplexity,Metrics/MethodLength,Metrics/PerceivedComplexity
+
+# Enable resque tasks and ensure that setup and work tasks have access to the environment
+require 'resque/tasks'
+task 'resque:setup' => :environment
+task 'resque:work' => :environment
+
+MAX_WAIT_TIME_TO_KILL_WORKERS = 120
+PIDFILE_PATH = 'tmp/pids/resque.pid'
+
+namespace :resque do
+ desc 'Stop current workers and start new workers'
+ task restart_workers: :environment do
+ Rake::Task['resque:stop_workers'].invoke
+ Rake::Task['resque:start_workers'].invoke
+ end
+
+ desc 'Stop running workers'
+ task stop_workers: :environment do
+ stop_workers
+ end
+
+ desc 'Start workers'
+ task start_workers: :environment do
+ start_workers(Rails.application.config_for(:resque))
+ end
+
+ def store_pids(pids, mode)
+ pids_to_store = pids
+ pids_to_store += read_pids if mode == :append
+
+ File.write(File.expand_path(PIDFILE_PATH, Rails.root), pids_to_store.join(','))
+ end
+
+ def read_pids
+ pid_file_path = File.expand_path(PIDFILE_PATH, Rails.root)
+ return [] unless File.exist?(pid_file_path)
+
+ File.read(pid_file_path).split(',').collect(&:to_i)
+ end
+
+ def stop_workers
+ pids = read_pids
+
+ if pids.empty?
+ puts 'No known workers to kill'
+ return
+ end
+
+ # First tell workers to stop accepting new work by sending USR2 signal
+ puts "\nTelling workers to finish current jobs, but not process any new jobs..."
+ syscmd = "kill -s USR2 #{pids.join(' ')}"
+ puts "$ #{syscmd}"
+ `#{syscmd}`
+ puts "\n"
+ puts 'Waiting for workers to finish current jobs...'
+ start_time = Time.current
+ while (Time.current - start_time) < MAX_WAIT_TIME_TO_KILL_WORKERS
+ sleep 1
+ num_workers_working = Resque.workers.count(&:working?)
+ puts "#{num_workers_working} workers still working..."
+ break if num_workers_working.zero?
+ end
+ puts "\n"
+ if Resque.workers.count(&:working?).positive?
+ puts "Workers are still running, but wait time of #{MAX_WAIT_TIME_TO_KILL_WORKERS} "\
+ 'has been exceeded. Sending QUIT signal anyway.'
+ else
+ puts 'Workers are no longer processing any jobs. Safely sending QUIT signal...'
+ end
+ syscmd = "kill -s QUIT #{pids.join(' ')}"
+ puts "$ #{syscmd}"
+ `#{syscmd}`
+ store_pids([], :write)
+ puts "\n"
+ puts 'Workers have been shut down.'
+
+ # Unregister old workers
+ Resque.workers.each(&:unregister_worker)
+ end
+
+ # Start a worker with proper env vars and output redirection
+ def start_workers(resque_config)
+ polling_interval = resque_config[:polling_interval]
+ worker_config = resque_config.fetch(:workers, {})
+
+ total_workers = 0
+ worker_info_string = worker_config.map { |queues, count|
+ total_workers += count
+ " [ #{queues} ] => #{count} #{count == 1 ? 'worker' : 'workers'}"
+ }.join("\n")
+ puts "Starting #{total_workers} #{total_workers == 1 ? 'worker' : 'workers'} "\
+ "with a polling interval of #{polling_interval} seconds:\n" + worker_info_string
+
+ ops = {
+ pgroup: true,
+ err: [Rails.root.join('log/resque_stderr').to_s, 'a'],
+ out: [Rails.root.join('log/resque_stdout').to_s, 'a']
+ }
+
+ pids = []
+ worker_config.each do |queues, count|
+ env_vars = {
+ 'QUEUES' => queues.to_s,
+ 'RAILS_ENV' => Rails.env.to_s,
+ 'INTERVAL' => polling_interval.to_s, # jobs tend to run for a while, so a 5-second checking interval is fine
+ 'TERM' => 'xterm'
+ }
+ count.times do
+ # Using Kernel.spawn and Process.detach because regular system() call would
+ # cause the processes to quit when capistrano finishes.
+ pid = spawn(env_vars, 'rake resque:work', ops)
+ Process.detach(pid)
+ pids << pid
+ end
+ end
+
+ store_pids(pids, :append)
+ end
+end
+
+# rubocop:enable Metrics/AbcSize,Metrics/CyclomaticComplexity,Metrics/MethodLength,Metrics/PerceivedComplexity
diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb
new file mode 100644
index 0000000..47a31bb
--- /dev/null
+++ b/spec/models/user_spec.rb
@@ -0,0 +1,5 @@
+require 'rails_helper'
+
+RSpec.describe User, type: :model do
+ pending "add some examples to (or delete) #{__FILE__}"
+end