From 0635239d0b14d5df601f0bc85363ce7680e64eab Mon Sep 17 00:00:00 2001 From: Matthew Zagaja Date: Tue, 17 Oct 2023 20:41:51 -0400 Subject: [PATCH] Resolve #414 Add Language Support (#474) * Return question text and response text in English by default, but use the headers provided by ACCEPT_LANGUAGE to pick the available language or the langPref param. * Raise exception on SurveyQuestion if language does not exist * Throw exception on survey show method if lang isn't available * Include localized response options as well * Update survey index method to only include id, title, timestamp and url --------- Co-authored-by: Linden Huhmann Co-authored-by: Mike Lynch Co-authored-by: Sunnkist Co-authored-by: Mike Lynch Co-authored-by: Anna Westland-Tegart --- backend/Gemfile | 4 +- backend/Gemfile.lock | 180 ++++++++++-------- backend/app/controllers/surveys_controller.rb | 12 +- .../app/models/localized_survey_question.rb | 5 + backend/app/models/survey_question.rb | 18 ++ .../app/views/surveys/_survey.json.jbuilder | 10 +- backend/app/views/surveys/index.json.jbuilder | 7 +- ...00437_create_localized_survey_questions.rb | 19 ++ backend/db/schema.rb | 19 +- backend/db/seed_importer.rb | 13 +- 10 files changed, 191 insertions(+), 96 deletions(-) create mode 100644 backend/app/models/localized_survey_question.rb create mode 100644 backend/db/migrate/20230920000437_create_localized_survey_questions.rb diff --git a/backend/Gemfile b/backend/Gemfile index 667a564f..469d236e 100644 --- a/backend/Gemfile +++ b/backend/Gemfile @@ -9,12 +9,14 @@ gem 'bootsnap', require: false gem 'devise' gem 'devise-jwt' gem 'faraday' +gem 'http_accept_language' gem 'jbuilder' gem 'newrelic_rpm' gem 'pg', '~> 1.1' gem 'puma', '~> 5.0' +gem 'rack', '~> 2.0' gem 'rack-cors', '~> 2.0' -gem 'rails', '~> 7.0.3', '>= 7.0.3.1' +gem 'rails', '>= 7.0' gem 'sprockets-rails' gem 'sucker_punch', '~> 3.0' gem 'tzinfo-data', platforms: %i[mingw mswin x64_mingw jruby] diff --git a/backend/Gemfile.lock b/backend/Gemfile.lock index b0f3317f..a0c479ff 100644 --- a/backend/Gemfile.lock +++ b/backend/Gemfile.lock @@ -1,74 +1,83 @@ GEM remote: https://rubygems.org/ specs: - actioncable (7.0.8) - actionpack (= 7.0.8) - activesupport (= 7.0.8) + actioncable (7.1.0) + actionpack (= 7.1.0) + activesupport (= 7.1.0) nio4r (~> 2.0) websocket-driver (>= 0.6.1) - actionmailbox (7.0.8) - actionpack (= 7.0.8) - activejob (= 7.0.8) - activerecord (= 7.0.8) - activestorage (= 7.0.8) - activesupport (= 7.0.8) + zeitwerk (~> 2.6) + actionmailbox (7.1.0) + actionpack (= 7.1.0) + activejob (= 7.1.0) + activerecord (= 7.1.0) + activestorage (= 7.1.0) + activesupport (= 7.1.0) mail (>= 2.7.1) net-imap net-pop net-smtp - actionmailer (7.0.8) - actionpack (= 7.0.8) - actionview (= 7.0.8) - activejob (= 7.0.8) - activesupport (= 7.0.8) + actionmailer (7.1.0) + actionpack (= 7.1.0) + actionview (= 7.1.0) + activejob (= 7.1.0) + activesupport (= 7.1.0) mail (~> 2.5, >= 2.5.4) net-imap net-pop net-smtp - rails-dom-testing (~> 2.0) - actionpack (7.0.8) - actionview (= 7.0.8) - activesupport (= 7.0.8) - rack (~> 2.0, >= 2.2.4) + rails-dom-testing (~> 2.2) + actionpack (7.1.0) + actionview (= 7.1.0) + activesupport (= 7.1.0) + nokogiri (>= 1.8.5) + rack (>= 2.2.4) + rack-session (>= 1.0.1) rack-test (>= 0.6.3) - rails-dom-testing (~> 2.0) - rails-html-sanitizer (~> 1.0, >= 1.2.0) - actiontext (7.0.8) - actionpack (= 7.0.8) - activerecord (= 7.0.8) - activestorage (= 7.0.8) - activesupport (= 7.0.8) + rails-dom-testing (~> 2.2) + rails-html-sanitizer (~> 1.6) + actiontext (7.1.0) + actionpack (= 7.1.0) + activerecord (= 7.1.0) + activestorage (= 7.1.0) + activesupport (= 7.1.0) globalid (>= 0.6.0) nokogiri (>= 1.8.5) - actionview (7.0.8) - activesupport (= 7.0.8) + actionview (7.1.0) + activesupport (= 7.1.0) builder (~> 3.1) - erubi (~> 1.4) - rails-dom-testing (~> 2.0) - rails-html-sanitizer (~> 1.1, >= 1.2.0) - activejob (7.0.8) - activesupport (= 7.0.8) + erubi (~> 1.11) + rails-dom-testing (~> 2.2) + rails-html-sanitizer (~> 1.6) + activejob (7.1.0) + activesupport (= 7.1.0) globalid (>= 0.3.6) - activemodel (7.0.8) - activesupport (= 7.0.8) - activerecord (7.0.8) - activemodel (= 7.0.8) - activesupport (= 7.0.8) - activestorage (7.0.8) - actionpack (= 7.0.8) - activejob (= 7.0.8) - activerecord (= 7.0.8) - activesupport (= 7.0.8) + activemodel (7.1.0) + activesupport (= 7.1.0) + activerecord (7.1.0) + activemodel (= 7.1.0) + activesupport (= 7.1.0) + timeout (>= 0.4.0) + activestorage (7.1.0) + actionpack (= 7.1.0) + activejob (= 7.1.0) + activerecord (= 7.1.0) + activesupport (= 7.1.0) marcel (~> 1.0) - mini_mime (>= 1.1.0) - activesupport (7.0.8) + activesupport (7.1.0) + base64 + bigdecimal concurrent-ruby (~> 1.0, >= 1.0.2) + connection_pool (>= 2.2.5) + drb i18n (>= 1.6, < 2) minitest (>= 5.1) + mutex_m tzinfo (~> 2.0) ast (2.4.2) base64 (0.1.1) bcrypt (3.1.19) + bigdecimal (3.1.4) bindex (0.8.1) bootsnap (1.16.0) msgpack (~> 1.2) @@ -80,6 +89,7 @@ GEM bundler (>= 1.2.0, < 3) thor (~> 1.0) concurrent-ruby (1.2.2) + connection_pool (2.4.1) crass (1.0.6) date (3.3.3) debug (1.8.0) @@ -95,6 +105,8 @@ GEM devise (~> 4.0) warden-jwt_auth (~> 0.8) diff-lcs (1.5.0) + drb (2.1.1) + ruby2_keywords dry-auto_inject (1.0.1) dry-core (~> 1.0) zeitwerk (~> 2.6) @@ -117,6 +129,7 @@ GEM faraday-net_http (3.0.2) globalid (1.2.1) activesupport (>= 6.1) + http_accept_language (2.1.1) i18n (1.14.1) concurrent-ruby (~> 1.0) io-console (0.6.0) @@ -129,7 +142,7 @@ GEM json (2.6.3) jwt (2.7.1) language_server-protocol (3.17.0.3) - loofah (2.21.3) + loofah (2.21.4) crass (~> 1.0.2) nokogiri (>= 1.12.0) mail (2.8.1) @@ -138,11 +151,11 @@ GEM net-pop net-smtp marcel (1.0.2) - method_source (1.0.0) mini_mime (1.1.5) minitest (5.20.0) msgpack (1.7.2) - net-imap (0.3.7) + mutex_m (0.1.2) + net-imap (0.4.1) date net-protocol net-pop (0.1.2) @@ -153,15 +166,11 @@ GEM net-protocol newrelic_rpm (9.5.0) nio4r (2.5.9) - nokogiri (1.15.4-arm64-darwin) - racc (~> 1.4) - nokogiri (1.15.4-x86_64-darwin) - racc (~> 1.4) nokogiri (1.15.4-x86_64-linux) racc (~> 1.4) orm_adapter (0.5.0) parallel (1.23.0) - parser (3.2.2.3) + parser (3.2.2.4) ast (~> 2.4.1) racc pg (1.5.4) @@ -173,22 +182,27 @@ GEM rack (2.2.8) rack-cors (2.0.1) rack (>= 2.0.0) + rack-session (1.0.1) + rack (< 3) rack-test (2.1.0) rack (>= 1.3) - rails (7.0.8) - actioncable (= 7.0.8) - actionmailbox (= 7.0.8) - actionmailer (= 7.0.8) - actionpack (= 7.0.8) - actiontext (= 7.0.8) - actionview (= 7.0.8) - activejob (= 7.0.8) - activemodel (= 7.0.8) - activerecord (= 7.0.8) - activestorage (= 7.0.8) - activesupport (= 7.0.8) + rackup (1.0.0) + rack (< 3) + webrick + rails (7.1.0) + actioncable (= 7.1.0) + actionmailbox (= 7.1.0) + actionmailer (= 7.1.0) + actionpack (= 7.1.0) + actiontext (= 7.1.0) + actionview (= 7.1.0) + activejob (= 7.1.0) + activemodel (= 7.1.0) + activerecord (= 7.1.0) + activestorage (= 7.1.0) + activesupport (= 7.1.0) bundler (>= 1.15.0) - railties (= 7.0.8) + railties (= 7.1.0) rails-dom-testing (2.2.0) activesupport (>= 5.0.0) minitest @@ -196,19 +210,20 @@ GEM rails-html-sanitizer (1.6.0) loofah (~> 2.21) nokogiri (~> 1.14) - railties (7.0.8) - actionpack (= 7.0.8) - activesupport (= 7.0.8) - method_source + railties (7.1.0) + actionpack (= 7.1.0) + activesupport (= 7.1.0) + irb + rackup (>= 1.0.0) rake (>= 12.2) - thor (~> 1.0) - zeitwerk (~> 2.5) + thor (~> 1.0, >= 1.2.2) + zeitwerk (~> 2.6) rainbow (3.1.1) rake (13.0.6) rdoc (6.5.0) psych (>= 4.0.0) - regexp_parser (2.8.1) - reline (0.3.8) + regexp_parser (2.8.2) + reline (0.3.9) io-console (~> 0.5) responders (3.1.0) actionpack (>= 5.2) @@ -231,7 +246,7 @@ GEM rspec-mocks (~> 3.12) rspec-support (~> 3.12) rspec-support (3.12.1) - rubocop (1.56.3) + rubocop (1.56.4) base64 (~> 0.1.1) json (~> 2.3) language_server-protocol (>= 3.17.0) @@ -245,7 +260,7 @@ GEM unicode-display_width (>= 2.4.0, < 3.0) rubocop-ast (1.29.0) parser (>= 3.2.1.0) - rubocop-rails (2.21.1) + rubocop-rails (2.21.2) activesupport (>= 4.2.0) rack (>= 1.1) rubocop (>= 1.33.0, < 2.0) @@ -267,7 +282,7 @@ GEM timeout (0.4.0) tzinfo (2.0.6) concurrent-ruby (~> 1.0) - unicode-display_width (2.4.2) + unicode-display_width (2.5.0) warden (1.2.9) rack (>= 2.0.9) warden-jwt_auth (0.8.0) @@ -280,14 +295,13 @@ GEM activemodel (>= 6.0.0) bindex (>= 0.4.0) railties (>= 6.0.0) + webrick (1.8.1) websocket-driver (0.7.6) websocket-extensions (>= 0.1.0) websocket-extensions (0.1.5) zeitwerk (2.6.12) PLATFORMS - arm64-darwin-21 - universal-darwin-22 x86_64-linux DEPENDENCIES @@ -299,12 +313,14 @@ DEPENDENCIES devise-jwt factory_bot_rails faraday + http_accept_language jbuilder newrelic_rpm pg (~> 1.1) puma (~> 5.0) + rack (~> 2.0) rack-cors (~> 2.0) - rails (~> 7.0.3, >= 7.0.3.1) + rails (>= 7.0) rspec-rails (~> 6.0.0) rubocop rubocop-rails @@ -316,7 +332,7 @@ DEPENDENCIES web-console RUBY VERSION - ruby 3.1.3p185 + ruby 3.1.4p223 BUNDLED WITH - 2.3.24 + 2.3.26 diff --git a/backend/app/controllers/surveys_controller.rb b/backend/app/controllers/surveys_controller.rb index 59dbec4e..4b63b139 100644 --- a/backend/app/controllers/surveys_controller.rb +++ b/backend/app/controllers/surveys_controller.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true class SurveysController < ApplicationController + AVAILABLE_LANGUAGES = %w[en es fr-ht pt-br].freeze before_action :set_survey, only: %i[show edit update destroy] # GET /surveys or /surveys.json @@ -9,7 +10,16 @@ def index end # GET /surveys/1 or /surveys/1.json - def show; end + def show + @language_code = (params[:langPref].presence || http_accept_language.preferred_language_from(AVAILABLE_LANGUAGES)) + + # If first question of survey doesn't have this localization, then throw exception + # (No need to check all questions for this localization) + return unless @survey.survey_questions.first.localized_survey_questions.find_by(language_code: @language_code).nil? + + raise StandardError, + "Survey question #{@survey.id} does not have localization '#{@language_code}'" + end # GET /surveys/new def new diff --git a/backend/app/models/localized_survey_question.rb b/backend/app/models/localized_survey_question.rb new file mode 100644 index 00000000..484d6a3f --- /dev/null +++ b/backend/app/models/localized_survey_question.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +class LocalizedSurveyQuestion < ApplicationRecord + belongs_to :survey_question +end diff --git a/backend/app/models/survey_question.rb b/backend/app/models/survey_question.rb index 3a9b6bea..15867f58 100644 --- a/backend/app/models/survey_question.rb +++ b/backend/app/models/survey_question.rb @@ -3,6 +3,24 @@ class SurveyQuestion < ApplicationRecord belongs_to :survey enum response_type: { radio: 0, text: 1 } + has_many :localized_survey_questions, dependent: nil + accepts_nested_attributes_for :localized_survey_questions validates :display_order, uniqueness: { scope: :survey_id } + + def text(language_code) + lsq = localized_survey_questions.find_by(language_code:) + + raise StandardError, "Survey question #{id} does not have localization '#{language_code}'" if lsq.nil? + + lsq.text + end + + def response_options(language_code) + lsq = localized_survey_questions.find_by(language_code:) + + raise StandardError, "Survey question #{id} does not have localization '#{language_code}'" if lsq.nil? + + lsq.response_options + end end diff --git a/backend/app/views/surveys/_survey.json.jbuilder b/backend/app/views/surveys/_survey.json.jbuilder index 063c8705..8656b498 100644 --- a/backend/app/views/surveys/_survey.json.jbuilder +++ b/backend/app/views/surveys/_survey.json.jbuilder @@ -1,9 +1,11 @@ # frozen_string_literal: true json.extract! survey, :id, :title, :updated_at -json.survey_questions do - json.array!(survey.survey_questions.sort_by do |el| - el[:display_order] - end, :id, :display_order, :text, :response_type, :response_options) +json.survey_questions @survey.survey_questions.sort_by(&:display_order) do |sq| + json.id sq.id + json.display_order sq.display_order + json.response_type sq.response_type + json.question sq.text(@language_code) + json.response_options sq.response_options(@language_code) end json.url survey_url(survey, format: :json) diff --git a/backend/app/views/surveys/index.json.jbuilder b/backend/app/views/surveys/index.json.jbuilder index cdf31851..d7e40fbf 100644 --- a/backend/app/views/surveys/index.json.jbuilder +++ b/backend/app/views/surveys/index.json.jbuilder @@ -1,3 +1,8 @@ # frozen_string_literal: true -json.array! @surveys, partial: 'surveys/survey', as: :survey +json.array! @surveys do |s| + json.id s.id + json.title s.title + json.updated_at s.updated_at + json.url survey_url(s.id, format: :json) +end diff --git a/backend/db/migrate/20230920000437_create_localized_survey_questions.rb b/backend/db/migrate/20230920000437_create_localized_survey_questions.rb new file mode 100644 index 00000000..ade0b8f0 --- /dev/null +++ b/backend/db/migrate/20230920000437_create_localized_survey_questions.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +class CreateLocalizedSurveyQuestions < ActiveRecord::Migration[7.0] + def change + create_table :localized_survey_questions do |t| + t.string :language_code + t.text :text + t.string :response_options, array: true + t.references :survey_question + + t.timestamps + end + + change_table :survey_questions, bulk: true do |t| + t.remove :text, type: :text + t.remove :response_options, type: :string, array: true + end + end +end diff --git a/backend/db/schema.rb b/backend/db/schema.rb index 08806692..d43e420f 100644 --- a/backend/db/schema.rb +++ b/backend/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.0].define(version: 2023_07_12_003916) do +ActiveRecord::Schema[7.0].define(version: 2023_09_20_000437) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -41,8 +41,6 @@ t.datetime "updated_at", null: false t.bigint "assignment_id" t.integer "visit_order" - t.integer "status", default: 0, null: false - t.boolean "user_added", default: false, null: false t.integer "bedroom_score" t.integer "floor_score" t.string "geometry" @@ -56,6 +54,8 @@ t.integer "total_score_x" t.string "yr_built_category" t.integer "yr_built_score" + t.integer "status", default: 0, null: false + t.boolean "user_added", default: false, null: false t.index ["assignment_id", "visit_order"], name: "index_homes_on_assignment_id_and_visit_order", unique: true t.index ["assignment_id"], name: "index_homes_on_assignment_id" end @@ -68,6 +68,16 @@ t.index ["jti"], name: "index_jwt_denylists_on_jti" end + create_table "localized_survey_questions", force: :cascade do |t| + t.string "language_code" + t.text "text" + t.string "response_options", array: true + t.bigint "survey_question_id" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["survey_question_id"], name: "index_localized_survey_questions_on_survey_question_id" + end + create_table "survey_answers", force: :cascade do |t| t.text "answer" t.bigint "survey_response_id", null: false @@ -79,14 +89,11 @@ end create_table "survey_questions", force: :cascade do |t| - t.text "text" t.integer "response_type" - t.string "response_options", array: true t.bigint "survey_id", null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false t.integer "display_order", default: 0, null: false - t.index ["response_options"], name: "index_survey_questions_on_response_options", using: :gin t.index ["survey_id", "display_order"], name: "index_survey_questions_on_survey_id_and_display_order", unique: true t.index ["survey_id"], name: "index_survey_questions_on_survey_id" end diff --git a/backend/db/seed_importer.rb b/backend/db/seed_importer.rb index 747cc890..520f67cb 100644 --- a/backend/db/seed_importer.rb +++ b/backend/db/seed_importer.rb @@ -25,7 +25,18 @@ def import_seed_data(path, clustered_ordered_parcels) chunk.each do |data_hash| data_hash[:response_options] = (data_hash[:response_options].nil? ? [] : data_hash[:response_options].split('/', -1)) - question = SurveyQuestion.new(data_hash) + fixed_data_hash = { + display_order: data_hash[:display_order], + response_type: data_hash[:response_type], + localized_survey_questions: [ + LocalizedSurveyQuestion.new({ + text: data_hash[:text], + response_options: data_hash[:response_options], + language_code: 'en' + }) + ] + } + question = SurveyQuestion.new(fixed_data_hash) question.survey = survey question.save! end