diff --git a/.env.sample b/.env.sample index bc3acfecb..60257a571 100644 --- a/.env.sample +++ b/.env.sample @@ -23,3 +23,6 @@ RESPONSIBLE_EMAIL= SEND_EMAILS_IN_DEV=false MAPBOX_API_KEY= + +GOOGLE_CLIENT_ID= +GOOGLE_API_KEY= diff --git a/Gemfile b/Gemfile index 83f31bcfd..6319f6ac4 100644 --- a/Gemfile +++ b/Gemfile @@ -96,6 +96,9 @@ gem "paper_trail" # Interactors gem "interactor", "~> 3.0" +# Translations +gem "google-cloud-translate" + # Error Management gem "sentry-rails" gem "sentry-ruby" diff --git a/Gemfile.lock b/Gemfile.lock index 95a0c7708..fccfed3ae 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -258,6 +258,29 @@ GEM railties (>= 5.0.0) faker (3.2.2) i18n (>= 1.8.11, < 2) + faraday (1.10.3) + faraday-em_http (~> 1.0) + faraday-em_synchrony (~> 1.0) + faraday-excon (~> 1.1) + faraday-httpclient (~> 1.0) + faraday-multipart (~> 1.0) + faraday-net_http (~> 1.0) + faraday-net_http_persistent (~> 1.0) + faraday-patron (~> 1.0) + faraday-rack (~> 1.0) + faraday-retry (~> 1.0) + ruby2_keywords (>= 0.0.4) + faraday-em_http (1.0.0) + faraday-em_synchrony (1.0.0) + faraday-excon (1.1.0) + faraday-httpclient (1.0.1) + faraday-multipart (1.0.4) + multipart-post (~> 2) + faraday-net_http (1.0.1) + faraday-net_http_persistent (1.2.0) + faraday-patron (1.0.0) + faraday-rack (1.0.0) + faraday-retry (1.0.3) ferrum (0.14) addressable (~> 2.5) concurrent-ruby (~> 1.1) @@ -270,14 +293,62 @@ GEM formtastic (4.0.0) actionpack (>= 5.2.0) formtastic_i18n (0.7.0) + gapic-common (0.20.0) + faraday (>= 1.9, < 3.a) + faraday-retry (>= 1.0, < 3.a) + google-protobuf (~> 3.14) + googleapis-common-protos (>= 1.3.12, < 2.a) + googleapis-common-protos-types (>= 1.3.1, < 2.a) + googleauth (~> 1.0) + grpc (~> 1.36) globalid (1.2.1) activesupport (>= 6.1) globalize (6.2.1) activemodel (>= 4.2, < 7.1) activerecord (>= 4.2, < 7.1) request_store (~> 1.0) + google-cloud-core (1.6.1) + google-cloud-env (>= 1.0, < 3.a) + google-cloud-errors (~> 1.0) + google-cloud-env (2.0.1) + faraday (>= 1.0, < 3.a) + google-cloud-errors (1.3.1) + google-cloud-translate (3.4.1) + google-cloud-core (~> 1.6) + google-cloud-translate-v2 (>= 0.0, < 2.a) + google-cloud-translate-v3 (>= 0.6, < 2.a) + google-cloud-translate-v2 (0.4.1) + faraday (>= 0.17.3, < 2.a) + google-cloud-core (~> 1.6) + googleapis-common-protos (>= 1.3.10, < 2.a) + googleapis-common-protos-types (>= 1.0.5, < 2.a) + googleauth (>= 0.16.2, < 2.a) + google-cloud-translate-v3 (0.9.0) + gapic-common (>= 0.20.0, < 2.a) + google-cloud-errors (~> 1.0) + google-protobuf (3.25.1) + google-protobuf (3.25.1-x86_64-linux) + googleapis-common-protos (1.4.0) + google-protobuf (~> 3.14) + googleapis-common-protos-types (~> 1.2) + grpc (~> 1.27) + googleapis-common-protos-types (1.11.0) + google-protobuf (~> 3.18) + googleauth (1.9.0) + faraday (>= 1.0, < 3.a) + google-cloud-env (~> 2.0, >= 2.0.1) + jwt (>= 1.4, < 3.0) + multi_json (~> 1.11) + os (>= 0.9, < 2.0) + signet (>= 0.16, < 2.a) groupdate (6.4.0) activesupport (>= 6.1) + grpc (1.60.0) + google-protobuf (~> 3.25) + googleapis-common-protos-types (~> 1.0) + grpc (1.60.0-x86_64-linux) + google-protobuf (~> 3.25) + googleapis-common-protos-types (~> 1.0) has_scope (0.8.2) actionpack (>= 5.2) activesupport (>= 5.2) @@ -365,6 +436,8 @@ GEM minitest (5.20.0) mjml-rails (4.9.0) msgpack (1.7.2) + multi_json (1.15.0) + multipart-post (2.3.0) mustache (1.1.1) net-imap (0.4.7) date @@ -389,6 +462,7 @@ GEM oj_mimic_json (1.0.1) optimist (3.1.0) orm_adapter (0.5.0) + os (1.1.4) paper_trail (12.3.0) activerecord (>= 5.2) request_store (~> 1.1) @@ -582,6 +656,11 @@ GEM connection_pool (>= 2.3.0) rack (>= 2.2.4) redis-client (>= 0.14.0) + signet (0.18.0) + addressable (~> 2.8) + faraday (>= 0.17.5, < 3.a) + jwt (>= 1.5, < 3.0) + multi_json (~> 1.10) simplecov (0.22.0) docile (~> 1.1) simplecov-html (~> 0.11) @@ -695,6 +774,7 @@ DEPENDENCIES faker globalize globalize-versioning! + google-cloud-translate groupdate http i18n_generators diff --git a/app/admin/observation.rb b/app/admin/observation.rb index e4965f45d..da7634ad7 100644 --- a/app/admin/observation.rb +++ b/app/admin/observation.rb @@ -103,6 +103,11 @@ def permitted_params end end + member_action :force_translations, method: :put do + TranslationJob.perform_later(resource, I18n.locale) + redirect_to edit_admin_observation_path(resource), notice: I18n.t("active_admin.observations_page.translating_observation") + end + action_item :ready_for_publication, only: :show do if resource.validation_status == "QC in progress" link_to I18n.t("active_admin.observations_page.ready_for_publication"), ready_for_publication_admin_observation_path(observation), @@ -581,10 +586,16 @@ def permitted_params end f.inputs I18n.t("active_admin.shared.translated_fields") do + div do + link_to I18n.t("active_admin.observations_page.force_translations"), force_translations_admin_observation_path(resource), method: :put, class: 'button' + end f.translated_inputs "Translations", switch_locale: false do |t| t.input :details, **visibility + t.input :details_translated_from, input_html: {disabled: true} t.input :concern_opinion, **visibility + t.input :concern_opinion_translated_from, input_html: {disabled: true} t.input :litigation_status, **visibility + t.input :litigation_status_translated_from, input_html: {disabled: true} end end f.actions diff --git a/app/jobs/translation_job.rb b/app/jobs/translation_job.rb new file mode 100644 index 000000000..eac8f6180 --- /dev/null +++ b/app/jobs/translation_job.rb @@ -0,0 +1,42 @@ +class TranslationJob < ApplicationJob + class TranslationException < StandardError + end + + queue_as :default + retry_on TranslationException, wait: 5.minutes, attempts: 3 + + # Takes an entity (an ActiveRecord object) and an original_locale, and translates all the fields based on that locale + # The model should have a TRANSLATABLE_FIELDS constant + def perform(entity, original_locale) + return unless entity.class.const_defined?(:AUTOMATICALLY_TRANSLATABLE_FIELDS) + + fields = entity.class.const_get(:AUTOMATICALLY_TRANSLATABLE_FIELDS) + translation_service = TranslationService.new + translated_fields = {} + + fields.each do |field| + translated_fields[field] = {} + I18n.available_locales.each do |locale| + next if locale == original_locale + + I18n.with_locale original_locale do + translated_fields[field][locale] = translation_service.call(entity.send(field), I18n.locale, locale) + end + end + end + + translated_fields.each do |field, translation| + translation.each do |locale, text| + I18n.with_locale locale do + entity.send("#{field}=", text) + entity.translation.send("#{field}_translated_from=", "#{original_locale}") + end + end + end + entity.save + + rescue e + Sentry.capture_exception(e) + raise TranslationException + end +end diff --git a/app/models/observation.rb b/app/models/observation.rb index 491986104..9838551c7 100644 --- a/app/models/observation.rb +++ b/app/models/observation.rb @@ -63,6 +63,8 @@ class Observation < ApplicationRecord validate_enum_attributes :observation_type, :evidence_type, :location_accuracy + AUTOMATICALLY_TRANSLATABLE_FIELDS = %w[details concern_opinion litigation_status] + STATUS_TRANSITIONS = { monitor: { "Created" => ["Ready for QC"], @@ -85,6 +87,7 @@ class Observation < ApplicationRecord ].freeze attr_accessor :user_type + attr_accessor :admin_locale belongs_to :country, inverse_of: :observations belongs_to :severity, inverse_of: :observations, optional: true @@ -151,6 +154,7 @@ class Observation < ApplicationRecord after_save :remove_documents, if: -> { evidence_type == "Evidence presented in the report" } after_save :update_fmu_geojson + after_save :force_translations, if: :saved_change_to_validation_status? after_commit :notify_about_creation, on: :create after_commit :notify_about_changes, if: :saved_change_to_validation_status? @@ -362,4 +366,11 @@ def notify_users(users, mail_template) end end end + + def force_translations + return unless published? + return unless I18n.available_locales.include? admin_locale + + TranslationJob.perform_later(resource, admin_locale) + end end diff --git a/app/resources/v1/observation_resource.rb b/app/resources/v1/observation_resource.rb index 2bde818f6..f4c16296a 100644 --- a/app/resources/v1/observation_resource.rb +++ b/app/resources/v1/observation_resource.rb @@ -120,10 +120,11 @@ def set_user @model.user_id = context[:current_user].id end - # Saves the last user who modified the observation + # Saves the last user who modified the observation and its locale def set_modified user = context[:current_user] @model.modified_user_id = user.id + @model.admin_locale = user.locale end # Makes sure the validation status can be an acceptable one diff --git a/app/services/translation_service.rb b/app/services/translation_service.rb new file mode 100644 index 000000000..54212d57a --- /dev/null +++ b/app/services/translation_service.rb @@ -0,0 +1,15 @@ + +# Service that communicates with the Google Translator API +class TranslationService + def initialize + @translator = Google::Cloud::Translate.translation_v2_service( + key: ENV["GOOGLE_API_KEY"] + ) + end + + def call(text, from, to) + translation = @translator.translate text, from: from, to: to + + translation.text + end +end diff --git a/config/locales/en.yml b/config/locales/en.yml index 0e4413175..9eb36535d 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -379,6 +379,8 @@ en: orphaned: 'Orphaned' details: 'Operator Document Annex Details' observations_page: + force_translations: 'Force translations' + translating_observation: 'Translating observation' not_modified: 'Observation NOT modified' perform_qc: 'Perform Quality Control' performed_qc: 'Quality Control performed' diff --git a/config/locales/fr.yml b/config/locales/fr.yml index 5c8373b32..73dde21fb 100644 --- a/config/locales/fr.yml +++ b/config/locales/fr.yml @@ -388,6 +388,8 @@ fr: orphaned: 'Orphelin' details: "Détails de l'annexe du document de l'opérateur" observations_page: + force_translations: 'Forcer les traductions' + translating_observation: "L'observation est en train d'être traduite" not_modified: 'Observation NON modifiée' perform_qc: 'Effectuer un contrôle qualité' performed_qc: 'Contrôle qualité effectué' diff --git a/db/migrate/20240128163512_add_automatic_translation_field_to_observation_translations.rb b/db/migrate/20240128163512_add_automatic_translation_field_to_observation_translations.rb new file mode 100644 index 000000000..9f8f8f73b --- /dev/null +++ b/db/migrate/20240128163512_add_automatic_translation_field_to_observation_translations.rb @@ -0,0 +1,7 @@ +class AddAutomaticTranslationFieldToObservationTranslations < ActiveRecord::Migration[7.0] + def change + add_column :observation_translations, :details_translated_from, :string + add_column :observation_translations, :concern_opinion_translated_from, :string + add_column :observation_translations, :litigation_status_translated_from, :string + end +end diff --git a/db/schema.rb b/db/schema.rb index a95e4f220..8b6d5f8dc 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[7.0].define(version: 2023_11_30_113839) do +ActiveRecord::Schema[7.0].define(version: 2024_01_28_163512) do # These are extensions that must be enabled in order to support this database enable_extension "address_standardizer" enable_extension "address_standardizer_data_us" @@ -510,6 +510,9 @@ t.text "concern_opinion" t.string "litigation_status" t.datetime "deleted_at", precision: nil + t.string "details_translated_from" + t.string "concern_opinion_translated_from" + t.string "litigation_status_translated_from" t.index ["deleted_at"], name: "index_observation_translations_on_deleted_at" t.index ["locale"], name: "index_observation_translations_on_locale" t.index ["observation_id"], name: "index_observation_translations_on_observation_id"