From 0b967819dd84039e1e97e6a2de7aa76a84ca45bb Mon Sep 17 00:00:00 2001 From: Devin Gaffney Date: Sun, 18 Aug 2024 07:39:50 -0700 Subject: [PATCH 01/14] CV2-5086 Move Smooch NLU to presto --- app/lib/smooch_nlu.rb | 6 +++--- app/models/concerns/alegre_v2.rb | 37 ++++++++++++++++++++++++++------ 2 files changed, 33 insertions(+), 10 deletions(-) diff --git a/app/lib/smooch_nlu.rb b/app/lib/smooch_nlu.rb index 01e15fb32f..1b44904807 100644 --- a/app/lib/smooch_nlu.rb +++ b/app/lib/smooch_nlu.rb @@ -46,13 +46,13 @@ def update_keywords(language, keywords, keyword, operation, doc_id, context) keywords << keyword alegre_operation = 'post' alegre_params = common_alegre_params.merge({ text: keyword, models: ALEGRE_MODELS_AND_THRESHOLDS.keys }) + Bot::Alegre.get_sync_raw_params(alegre_params, "text") if alegre_operation && alegre_params elsif operation == 'remove' keywords -= [keyword] alegre_operation = 'delete' alegre_params = common_alegre_params.merge({ quiet: true }) + Bot::Alegre.request_delete_from_raw(alegre_params, "text") if alegre_operation && alegre_params end - # FIXME: Add error handling and better logging - Bot::Alegre.request(alegre_operation, '/text/similarity/', alegre_params) if alegre_operation && alegre_params keywords end @@ -91,7 +91,7 @@ def self.alegre_matches_from_message(message, language, context, alegre_result_k language: language, }.merge(context) } - response = Bot::Alegre.request('post', '/text/similarity/search/', params) + response = Bot::Alegre.get_sync_raw_params(params, "text") # One approach would be to take the option that has the most matches # Unfortunately this approach is influenced by the number of keywords per option diff --git a/app/models/concerns/alegre_v2.rb b/app/models/concerns/alegre_v2.rb index 2dde5fc159..d93be886b7 100644 --- a/app/models/concerns/alegre_v2.rb +++ b/app/models/concerns/alegre_v2.rb @@ -1,7 +1,7 @@ require 'active_support/concern' class AlegreTimeoutError < StandardError; end class TemporaryProjectMedia - attr_accessor :team_id, :id, :url, :type + attr_accessor :team_id, :id, :url, :text, :type def media media_type_map = { "claim" => "Claim", @@ -55,11 +55,18 @@ def sync_path_for_type(type) end def async_path(project_media) - "/similarity/async/#{get_type(project_media)}" + self.async_path_for_type(get_type(project_media)) + end + + def async_path_for_type(type) + "/similarity/async/#{type}" end def delete_path(project_media) - type = get_type(project_media) + self.delete_path_for_type(get_type(project_media)) + end + + def delete_path_for_type(type) "/#{type}/similarity/" end @@ -122,6 +129,10 @@ def request(method, path, params, retries=3) end end + def request_delete_from_raw(params, type) + request("delete", delete_path_for_type(type), params) + end + def request_delete(data, project_media) request("delete", delete_path(project_media), data) end @@ -148,18 +159,22 @@ def get_type(project_media) type end + def content_hash_for_value(value) + Digest::MD5.hexdigest(value) + end + def content_hash(project_media, field) if Bot::Alegre::ALL_TEXT_SIMILARITY_FIELDS.include?(field) - Digest::MD5.hexdigest(project_media.send(field)) + content_hash_for_value(project_media.send(field)) else if project_media.is_link? - return Digest::MD5.hexdigest(project_media.media.url) + return content_hash_for_value(project_media.media.url) elsif project_media.is_a?(TemporaryProjectMedia) return Rails.cache.read("url_sha:#{project_media.url}") elsif !project_media.is_text? return project_media.media.file.filename.split(".").first else - return Digest::MD5.hexdigest(project_media.send(field).to_s) + return content_hash_for_value(project_media.send(field).to_s) end end end @@ -267,6 +282,14 @@ def store_package_text(project_media, field, params) generic_package_text(project_media, field, params) end + def get_sync_raw_params(params, type) + request("post", sync_path_for_type(type), params) + end + + def get_async_raw_params(params, type) + request("post", async_path_for_type(type), params) + end + def get_sync(project_media, field=nil, params={}) request_sync( store_package(project_media, field, params), @@ -537,4 +560,4 @@ def restrict_contexts(project_media, project_media_id_scores) }] end end -end +end \ No newline at end of file From f6331659386d882b1efb18a26e2b33b6a848aa27 Mon Sep 17 00:00:00 2001 From: Devin Gaffney Date: Sun, 18 Aug 2024 12:20:39 -0700 Subject: [PATCH 02/14] update stubs --- test/models/bot/smooch_6_test.rb | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/test/models/bot/smooch_6_test.rb b/test/models/bot/smooch_6_test.rb index 6473d95dc2..5ecdad193e 100644 --- a/test/models/bot/smooch_6_test.rb +++ b/test/models/bot/smooch_6_test.rb @@ -693,7 +693,7 @@ def send_message_outside_24_hours_window(template, pm = nil) pm = create_project_media team: @team publish_report(pm, {}, nil, { language: 'pt', use_visual_card: false }) Bot::Smooch.stubs(:get_search_results).returns([pm]) - WebMock.stub_request(:post, /\/text\/similarity\/search\//).to_return(body: {}.to_json) # For explainers + WebMock.stub_request(:post, /\/similarity\/sync\/text/).to_return(body: {}.to_json) # For explainers Sidekiq::Testing.inline! do send_message 'hello', '1', '1', 'Foo bar', '1' end @@ -807,9 +807,9 @@ def send_message_outside_24_hours_window(template, pm = nil) test 'should process menu option using NLU' do # Mock any call to Alegre like `POST /text/similarity/` with a "text" parameter that contains "want" - Bot::Alegre.stubs(:request).with{ |x, y, z| x == 'post' && y == '/text/similarity/' && z[:text] =~ /want/ }.returns(true) + Bot::Alegre.stubs(:request).with{ |x, y, z| x == 'post' && y == '/similarity/sync/text' && z[:text] =~ /want/ }.returns(true) # Mock any call to Alegre like `GET /text/similarity/` with a "text" parameter that does not contain "want" - Bot::Alegre.stubs(:request).with{ |x, y, z| x == 'post' && y == '/text/similarity/search/' && (z[:text] =~ /want/).nil? }.returns({ 'result' => [] }) + Bot::Alegre.stubs(:request).with{ |x, y, z| x == 'post' && y == '/similarity/sync/text' && (z[:text] =~ /want/).nil? }.returns({ 'result' => [] }) # Enable NLU and add a couple of keywords for the newsletter menu option nlu = SmoochNlu.new(@team.slug) @@ -822,7 +822,7 @@ def send_message_outside_24_hours_window(template, pm = nil) subscription_option_id = @installation.get_smooch_workflows[0]['smooch_state_main']['smooch_menu_options'][2]['smooch_menu_option_id'] # Mock a call to Alegre like `GET /text/similarity/` with a "text" parameter that contains "want" - Bot::Alegre.stubs(:request).with{ |x, y, z| x == 'post' && y == '/text/similarity/search/' && z[:text] =~ /want/ }.returns({ 'result' => [ + Bot::Alegre.stubs(:request).with{ |x, y, z| x == 'post' && y == '/similarity/sync/text' && z[:text] =~ /want/ }.returns({ 'result' => [ { '_score' => 0.9, '_source' => { 'context' => { 'menu_option_id' => subscription_option_id } } }, { '_score' => 0.2, '_source' => { 'context' => { 'menu_option_id' => query_option_id } } } ]}) @@ -836,7 +836,7 @@ def send_message_outside_24_hours_window(template, pm = nil) assert_state 'main' # Mock a call to Alegre like `GET /text/similarity/` with a "text" parameter that contains "want" - Bot::Alegre.stubs(:request).with{ |x, y, z| x == 'post' && y == '/text/similarity/search/' && z[:text] =~ /want/ }.returns({ 'result' => [ + Bot::Alegre.stubs(:request).with{ |x, y, z| x == 'post' && y == '/similarity/sync/text' && z[:text] =~ /want/ }.returns({ 'result' => [ { '_score' => 0.96, '_source' => { 'context' => { 'menu_option_id' => subscription_option_id } } }, { '_score' => 0.91, '_source' => { 'context' => { 'menu_option_id' => query_option_id } } } ]}) @@ -875,9 +875,9 @@ def send_message_outside_24_hours_window(template, pm = nil) Sidekiq::Testing.fake! do WebMock.disable_net_connect! allow: /#{CheckConfig.get('elasticsearch_host')}|#{CheckConfig.get('storage_endpoint')}/ # Mock any call to Alegre like `POST /text/similarity/` with a "text" parameter that contains "who are you" - Bot::Alegre.stubs(:request).with{ |x, y, z| x == 'post' && y == '/text/similarity/' && z[:text] =~ /who are you/ }.returns(true) + Bot::Alegre.stubs(:request).with{ |x, y, z| x == 'post' && y == '/similarity/sync/test' && z[:text] =~ /who are you/ }.returns(true) # Mock any call to Alegre like `GET /text/similarity/` with a "text" parameter that does not contain "who are you" - Bot::Alegre.stubs(:request).with{ |x, y, z| x == 'post' && y == '/text/similarity/search/' && (z[:text] =~ /who are you/).nil? }.returns({ 'result' => [] }) + Bot::Alegre.stubs(:request).with{ |x, y, z| x == 'post' && y == '/similarity/sync/test' && (z[:text] =~ /who are you/).nil? }.returns({ 'result' => [] }) # Enable NLU and add a couple of keywords to a new "About Us" resource nlu = SmoochNlu.new(@team.slug) @@ -887,7 +887,7 @@ def send_message_outside_24_hours_window(template, pm = nil) r.add_keyword('who are you') # Mock a call to Alegre like `GET /text/similarity/` with a "text" parameter that contains "who are you" - Bot::Alegre.stubs(:request).with{ |x, y, z| x == 'post' && y == '/text/similarity/search/' && z[:text] =~ /who are you/ }.returns({ 'result' => [ + Bot::Alegre.stubs(:request).with{ |x, y, z| x == 'post' && y == '/similarity/sync/text' && z[:text] =~ /who are you/ }.returns({ 'result' => [ { '_score' => 0.9, '_source' => { 'context' => { 'resource_id' => 0 } } }, { '_score' => 0.8, '_source' => { 'context' => { 'resource_id' => r.id } } } ]}) From aba56f84d123ff6db5f7e642a5d494557822ad76 Mon Sep 17 00:00:00 2001 From: Devin Gaffney Date: Sun, 18 Aug 2024 13:58:26 -0700 Subject: [PATCH 03/14] shift test stubs --- test/lib/smooch_nlu_test.rb | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/test/lib/smooch_nlu_test.rb b/test/lib/smooch_nlu_test.rb index 86e01015f8..588701d93c 100644 --- a/test/lib/smooch_nlu_test.rb +++ b/test/lib/smooch_nlu_test.rb @@ -64,7 +64,7 @@ def create_team_with_smooch_bot_installed team = create_team_with_smooch_bot_installed nlu = SmoochNlu.new(team.slug) nlu.enable! - Bot::Alegre.expects(:request).with{ |x, y, _z| x == 'post' && y == '/text/similarity/' }.once + Bot::Alegre.expects(:request).with{ |x, y, _z| x == 'post' && y == '/similarity/sync/text' }.once nlu.add_keyword_to_menu_option('en', 'main', 0, 'subscribe') expected_output = { 'en' => { @@ -85,7 +85,7 @@ def create_team_with_smooch_bot_installed end test 'should add keyword if it does not exist' do - Bot::Alegre.expects(:request).with{ |x, y, _z| x == 'post' && y == '/text/similarity/' }.once + Bot::Alegre.expects(:request).with{ |x, y, _z| x == 'post' && y == '/similarity/sync/text' }.once team = create_team_with_smooch_bot_installed SmoochNlu.new(team.slug).add_keyword_to_menu_option('en', 'main', 0, 'subscribe to the newsletter') end @@ -93,9 +93,9 @@ def create_team_with_smooch_bot_installed test 'should not add keyword if it exists' do team = create_team_with_smooch_bot_installed nlu = SmoochNlu.new(team.slug) - Bot::Alegre.expects(:request).with{ |x, y, _z| x == 'post' && y == '/text/similarity/' }.once + Bot::Alegre.expects(:request).with{ |x, y, _z| x == 'post' && y == '/similarity/sync/text' }.once nlu.add_keyword_to_menu_option('en', 'main', 0, 'subscribe to the newsletter') - Bot::Alegre.expects(:request).with{ |x, y, _z| x == 'post' && y == '/text/similarity/' }.never + Bot::Alegre.expects(:request).with{ |x, y, _z| x == 'post' && y == '/similarity/sync/text' }.never nlu.add_keyword_to_menu_option('en', 'main', 0, 'subscribe to the newsletter') end @@ -114,7 +114,7 @@ def create_team_with_smooch_bot_installed end test 'should return a menu option if NLU is enabled' do - Bot::Alegre.stubs(:request).with{ |x, y, z| x == 'post' && y == '/text/similarity/search/' && z[:text] =~ /newsletter/ }.returns({ 'result' => [ + Bot::Alegre.stubs(:request).with{ |x, y, z| x == 'post' && y == '/similarity/sync/text' && z[:text] =~ /newsletter/ }.returns({ 'result' => [ { '_score' => 0.9, '_source' => { 'context' => { 'menu_option_id' => 'test' } } }, ]}) team = create_team_with_smooch_bot_installed From 4b777f04876b1348ce1e1505335c5d5d6a4218e7 Mon Sep 17 00:00:00 2001 From: Devin Gaffney Date: Sun, 18 Aug 2024 16:10:28 -0700 Subject: [PATCH 04/14] minor tweak --- test/models/bot/smooch_6_test.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/models/bot/smooch_6_test.rb b/test/models/bot/smooch_6_test.rb index 5ecdad193e..7a1adddc5b 100644 --- a/test/models/bot/smooch_6_test.rb +++ b/test/models/bot/smooch_6_test.rb @@ -693,7 +693,7 @@ def send_message_outside_24_hours_window(template, pm = nil) pm = create_project_media team: @team publish_report(pm, {}, nil, { language: 'pt', use_visual_card: false }) Bot::Smooch.stubs(:get_search_results).returns([pm]) - WebMock.stub_request(:post, /\/similarity\/sync\/text/).to_return(body: {}.to_json) # For explainers + WebMock.stub_request(:post, /\/text\/similarity\/search/).to_return(body: {}.to_json) # For explainers Sidekiq::Testing.inline! do send_message 'hello', '1', '1', 'Foo bar', '1' end From f1f3b6cb7acee60daf7d2ef0b1fd357253a6a0cf Mon Sep 17 00:00:00 2001 From: Caio Almeida <117518+caiosba@users.noreply.github.com> Date: Sat, 24 Aug 2024 08:43:55 -0300 Subject: [PATCH 05/14] Export lists (#1992) Allow lists (core, custom, search results, articles, filtered or not) to be exported to a CSV file that is sent to e-mail. - [x] Add an `exportList` GraphQL mutation - [x] Implement a generic export class that supports media, articles and feeds - [x] Validate maximum number of results (which is a global configuration key) - [x] Validate permission - [x] Create Sidekiq job to export results - [x] Create a CSV for the export - [x] Save CSV in S3 using a pre-signed URL that expires after X days ("X" is a global configuration key) - [x] Add support to MailCatcher - [x] Send CSV by e-mail - [x] Automated tests - [x] Make sure it works for articles as well - [x] Make sure it works for shared feeds as well References: CV2-5067 and CV2-4979. --- app/graph/mutations/export_mutations.rb | 24 ++++ app/graph/types/mutation_type.rb | 2 + app/lib/check_config.rb | 1 + app/mailers/export_list_mailer.rb | 13 ++ app/models/ability.rb | 2 +- app/models/explainer.rb | 8 ++ app/models/fact_check.rb | 8 ++ app/models/feed.rb | 8 ++ .../export_list_mailer/send_csv.html.erb | 130 ++++++++++++++++++ .../export_list_mailer/send_csv.text.erb | 14 ++ config/config.yml.example | 11 +- config/environments/development.rb | 8 ++ config/initializers/plugins.rb | 2 +- config/locales/en.yml | 6 + lib/check_s3.rb | 9 ++ lib/check_search.rb | 45 +++++- lib/list_export.rb | 67 +++++++++ lib/relay.idl | 29 ++++ public/relay.json | 125 +++++++++++++++++ .../controllers/graphql_controller_11_test.rb | 47 ++++++- test/lib/check_s3_test.rb | 6 + test/lib/list_export_test.rb | 86 ++++++++++++ 22 files changed, 643 insertions(+), 8 deletions(-) create mode 100644 app/graph/mutations/export_mutations.rb create mode 100644 app/mailers/export_list_mailer.rb create mode 100644 app/views/export_list_mailer/send_csv.html.erb create mode 100644 app/views/export_list_mailer/send_csv.text.erb create mode 100644 lib/list_export.rb create mode 100644 test/lib/list_export_test.rb diff --git a/app/graph/mutations/export_mutations.rb b/app/graph/mutations/export_mutations.rb new file mode 100644 index 0000000000..a3dde76a1b --- /dev/null +++ b/app/graph/mutations/export_mutations.rb @@ -0,0 +1,24 @@ +module ExportMutations + class ExportList < Mutations::BaseMutation + argument :query, GraphQL::Types::String, required: true # JSON + argument :type, GraphQL::Types::String, required: true # 'media', 'feed', 'fact-check' or 'explainer' + + field :success, GraphQL::Types::Boolean, null: true + + def resolve(query:, type:) + ability = context[:ability] + team = Team.find_if_can(Team.current.id, ability) + if ability.cannot?(:export_list, team) + { success: false } + else + export = ListExport.new(type.to_sym, query, team.id) + if export.number_of_rows > CheckConfig.get(:export_csv_maximum_number_of_results, 10000, :integer) + { success: false } + else + export.generate_csv_and_send_email_in_background(User.current) + { success: true } + end + end + end + end +end diff --git a/app/graph/types/mutation_type.rb b/app/graph/types/mutation_type.rb index a87ea5528c..fbf56730b4 100644 --- a/app/graph/types/mutation_type.rb +++ b/app/graph/types/mutation_type.rb @@ -152,4 +152,6 @@ class MutationType < BaseObject field :createExplainerItem, mutation: ExplainerItemMutations::Create field :destroyExplainerItem, mutation: ExplainerItemMutations::Destroy + + field :exportList, mutation: ExportMutations::ExportList end diff --git a/app/lib/check_config.rb b/app/lib/check_config.rb index fe34a355c9..764f747063 100644 --- a/app/lib/check_config.rb +++ b/app/lib/check_config.rb @@ -3,6 +3,7 @@ class CheckConfig def self.get(key, default = nil, type = nil) + key = key.to_s value = ENV[key] value ||= CONFIG[key] if CONFIG.has_key?(key) return default if value.nil? diff --git a/app/mailers/export_list_mailer.rb b/app/mailers/export_list_mailer.rb new file mode 100644 index 0000000000..c9fed76b02 --- /dev/null +++ b/app/mailers/export_list_mailer.rb @@ -0,0 +1,13 @@ +class ExportListMailer < ApplicationMailer + layout nil + + def send_csv(csv_file_url, user) + @csv_file_url = csv_file_url + @user = user + expire_in = Time.now.to_i + CheckConfig.get('export_csv_expire', 7.days.to_i, :integer) + @expire_in = I18n.l(Time.at(expire_in), format: :email) + subject = I18n.t('mails_notifications.export_list.subject') + Rails.logger.info "Sending export e-mail to #{@user.email}" + mail(to: @user.email, email_type: 'export_list', subject: subject) + end +end diff --git a/app/models/ability.rb b/app/models/ability.rb index 42d45f7fa6..1db39e2c31 100644 --- a/app/models/ability.rb +++ b/app/models/ability.rb @@ -57,7 +57,7 @@ def admin_perms can :destroy, Team, :id => @context_team.id can :create, TeamUser, :team_id => @context_team.id, role: ['admin'] can [:update, :destroy], TeamUser, team_id: @context_team.id - can :duplicate, Team, :id => @context_team.id + can [:duplicate, :export_list], Team, :id => @context_team.id can :set_privacy, Project, :team_id => @context_team.id can :read_feed_invitations, Feed, :team_id => @context_team.id can :destroy, Feed, :team_id => @context_team.id diff --git a/app/models/explainer.rb b/app/models/explainer.rb index a4319e718a..5b55a57694 100644 --- a/app/models/explainer.rb +++ b/app/models/explainer.rb @@ -48,6 +48,14 @@ def update_paragraphs_in_alegre self.class.delay_for(5.seconds).update_paragraphs_in_alegre(self.id, previous_paragraphs_count, Time.now.to_f) end + def self.get_exported_data(query, team) + data = [['ID', 'Title', 'Description', 'URL', 'Language']] + team.filtered_explainers(query).find_each do |exp| + data << [exp.id, exp.title, exp.description, exp.url, exp.language] + end + data + end + def self.update_paragraphs_in_alegre(id, previous_paragraphs_count, timestamp) explainer = Explainer.find(id) diff --git a/app/models/fact_check.rb b/app/models/fact_check.rb index 5d42782496..5830494615 100644 --- a/app/models/fact_check.rb +++ b/app/models/fact_check.rb @@ -47,6 +47,14 @@ def update_item_status end end + def self.get_exported_data(query, team) + data = [['ID', 'Title', 'Summary', 'URL', 'Language', 'Report Status', 'Imported?']] + team.filtered_fact_checks(query).find_each do |fc| + data << [fc.id, fc.title, fc.summary, fc.url, fc.language, fc.report_status, fc.imported.to_s] + end + data + end + private def set_language diff --git a/app/models/feed.rb b/app/models/feed.rb index 05a652024f..e90c2c2d84 100755 --- a/app/models/feed.rb +++ b/app/models/feed.rb @@ -172,6 +172,14 @@ def saved_search_was SavedSearch.find_by_id(self.saved_search_id_before_last_save) end + def get_exported_data(filters) + data = [['Title', 'Number of media', 'Number of requests', 'Number of fact-checks']] + self.filtered_clusters(filters).find_each do |cluster| + data << [cluster.title, cluster.media_count, cluster.requests_count, cluster.fact_checks_count] + end + data + end + # This takes some time to run because it involves external HTTP requests and writes to the database: # 1) If the query contains a media URL, it will be downloaded... if it contains some other URL, it will be sent to Pender # 2) Requests will be made to Alegre in order to index the request media and to look for similar requests diff --git a/app/views/export_list_mailer/send_csv.html.erb b/app/views/export_list_mailer/send_csv.html.erb new file mode 100644 index 0000000000..081169fd8c --- /dev/null +++ b/app/views/export_list_mailer/send_csv.html.erb @@ -0,0 +1,130 @@ +<%= render "shared/header" %> + + + + + +
+ + + + +
 
+
+ + +
+
+
+ <%= I18n.t(:"mails_notifications.export_list.hello", name: @user.name) %> +
+ + + + +
 
+
+ <%= I18n.t("mails_notifications.export_list.subject") %> +
+ + + + +
 
+
+
+ <%= I18n.t(:"mails_notifications.export_list.body") %> +
+
+
+ + + + + +
 
+ + + + +
+ + + + + +
+ + + + + +
+ + <%= + link_to(I18n.t('mails_notifications.export_list.button_label'), + @csv_file_url, + :style => "text-decoration: none !important;color: #fff !important;" + ) + %> + + + <%= image_tag("https://images.ctfassets.net/g118h5yoccvd/#{@direction[:arrow]}", width: "7", alt: "arrow-icon", style: "-ms-interpolation-mode: bicubic; border: 0 none; height: auto; line-height: 100%; outline: none; text-decoration: none;") %> +
+
+ + + + + +
 
+ +
+
+ <%= I18n.t(:"mails_notifications.export_list.footer", date: @expire_in) %> +
+
+
+ + + + +
 
+ + +<%= render "shared/footer" %> diff --git a/app/views/export_list_mailer/send_csv.text.erb b/app/views/export_list_mailer/send_csv.text.erb new file mode 100644 index 0000000000..428c639142 --- /dev/null +++ b/app/views/export_list_mailer/send_csv.text.erb @@ -0,0 +1,14 @@ +<%= I18n.t('mails_notifications.export_list.hello', name: @user.name) %> + +<%= I18n.t('mails_notifications.export_list.subject') %> + +<%= I18n.t('mails_notifications.export_list.body') %> + +<%= I18n.t('mails_notifications.export_list.button_label') %>: <%= @csv_file_url %> + +<%= I18n.t('mails_notifications.export_list.footer', date: @expire_in ) %> + +... + +<%= strip_tags I18n.t("mails_notifications.copyright_html", app_name: CheckConfig.get('app_name')) %> +https://meedan.com diff --git a/config/config.yml.example b/config/config.yml.example index e14ec0ee77..44749ca977 100644 --- a/config/config.yml.example +++ b/config/config.yml.example @@ -160,6 +160,7 @@ development: &default smtp_user: # '' smtp_pass: # '' smtp_default_url_host: 'http://localhost:3333' # Used to construct URLs for links in email + smtp_mailcatcher_host: # 'host.docker.internal' # Pusher notification service https://pusher.com/channels # @@ -262,7 +263,7 @@ development: &default otel_traces_sampler: otel_custom_sampling_rate: - # Rate limits for tiplines + # Limits # # OPTIONAL # When not set, default values are used. @@ -270,12 +271,18 @@ development: &default tipline_user_max_messages_per_day: 1500 nlu_global_rate_limit: 100 nlu_user_rate_limit: 30 - devise_maximum_attempts: 5 devise_unlock_accounts_after: 1 login_rate_limit: 10 api_rate_limit: 100 + export_csv_maximum_number_of_results: 10000 + export_csv_expire: 604800 # Seconds: Default is 7 days + # Session + # + # OPTIONAL + # When not set, default values are used. + # session_store_key: '_checkdesk_session_dev' session_store_domain: 'localhost' test: diff --git a/config/environments/development.rb b/config/environments/development.rb index b8924f73e8..3f54f6587f 100644 --- a/config/environments/development.rb +++ b/config/environments/development.rb @@ -83,4 +83,12 @@ else puts '[WARNING] config.hosts not provided. Only requests from localhost are allowed. To change, update `whitelisted_hosts` in config.yml' end + + mailcatcher_host = ENV['smtp_mailcatcher_host'] || cfg['smtp_mailcatcher_host'] + unless mailcatcher_host.blank? + config.action_mailer.smtp_settings = { + address: mailcatcher_host, + port: 1025 + } + end end diff --git a/config/initializers/plugins.rb b/config/initializers/plugins.rb index 056a5b61a5..b928f936ff 100644 --- a/config/initializers/plugins.rb +++ b/config/initializers/plugins.rb @@ -1,2 +1,2 @@ # Load classes on boot, in production, that otherwise wouldn't be auto-loaded by default -CcDeville && Bot::Keep && Workflow::Workflow.workflows && CheckS3 && Bot::Tagger && Bot::Fetch && Bot::Smooch && Bot::Slack && Bot::Alegre && CheckChannels && RssFeed && UrlRewriter && ClusterTeam +CcDeville && Bot::Keep && Workflow::Workflow.workflows && CheckS3 && Bot::Tagger && Bot::Fetch && Bot::Smooch && Bot::Slack && Bot::Alegre && CheckChannels && RssFeed && UrlRewriter && ClusterTeam && ListExport diff --git a/config/locales/en.yml b/config/locales/en.yml index aa441378fc..e947f62ab1 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -476,6 +476,12 @@ en: constitutes acceptance of our updated Terms of Service. term_button: Terms of Service more_info: This is a one-time required legal notice sent to all Check users, even those who have unsubscribed by optional announcements. + export_list: + hello: Hello %{name} + subject: Check Data Export + body: Your requested Check data export is available to download. + button_label: Download Export + footer: This download link will expire on %{date}. mail_security: device_subject: 'Security alert: New login to %{app_name} from %{browser} on %{platform}' ip_subject: 'Security alert: New or unusual %{app_name} login' diff --git a/lib/check_s3.rb b/lib/check_s3.rb index af6d3c8288..7989cefbb4 100644 --- a/lib/check_s3.rb +++ b/lib/check_s3.rb @@ -65,4 +65,13 @@ def self.delete(*paths) client = Aws::S3::Client.new client.delete_objects(bucket: CheckConfig.get('storage_bucket'), delete: { objects: objects }) end + + def self.write_presigned(path, content_type, content, expires_in) + self.write(path, content_type, content) + bucket = CheckConfig.get('storage_bucket') + client = Aws::S3::Client.new + s3 = Aws::S3::Resource.new(client: client) + obj = s3.bucket(bucket).object(path) + obj.presigned_url(:get, expires_in: expires_in) + end end diff --git a/lib/check_search.rb b/lib/check_search.rb index cbcd41cea6..d8707e31a2 100644 --- a/lib/check_search.rb +++ b/lib/check_search.rb @@ -60,6 +60,10 @@ def initialize(options, file = nil, team_id = Team.current&.id) 'fact_check_published_on' => 'fact_check_published_on' } + def set_option(key, value) + @options[key] = value + end + def team_condition(team_id = nil) if feed_query? feed_teams = @options['feed_team_ids'].is_a?(Array) ? (@feed.team_ids & @options['feed_team_ids']) : @feed.team_ids @@ -329,12 +333,51 @@ def medias_get_search_result(query) @options['es_id'] ? $repository.find([@options['es_id']]).compact : $repository.search(query: query, collapse: collapse, sort: sort, size: @options['eslimit'], from: @options['esoffset']).results end + def self.get_exported_data(query, team_id) + team = Team.find(team_id) + search = CheckSearch.new(query, nil, team_id) + + # Prepare the export + data = [] + header = ['Claim', 'Item page URL', 'Status', 'Created by', 'Submitted at', 'Published at', 'Number of media', 'Tags'] + fields = team.team_tasks.sort + fields.each { |tt| header << tt.label } + data << header + + # No pagination for the export + search.set_option('esoffset', 0) + search.set_option('eslimit', CheckConfig.get(:export_csv_maximum_number_of_results, 10000, :integer)) + + # Iterate through each result and generate an output row for the CSV + search.medias.find_each do |pm| + row = [ + pm.claim_description&.description, + pm.full_url, + pm.status_i18n, + pm.author_name.to_s.gsub(/ \[.*\]$/, ''), + pm.created_at.strftime("%Y-%m-%d %H:%M:%S"), + pm.published_at&.strftime("%Y-%m-%d %H:%M:%S"), + pm.linked_items_count(true), + pm.tags_as_sentence(true) + ] + annotations = pm.get_annotations('task').map(&:load) + fields.each do |field| + annotation = annotations.find { |a| a.team_task_id == field.id } + answer = (annotation ? (begin annotation.first_response_obj.file_data[:file_urls].join("\n") rescue annotation.first_response.to_s end) : '') + answer = begin JSON.parse(answer).collect{ |x| x['url'] }.join(', ') rescue answer end + row << answer + end + data << row + end + data + end + private def adjust_es_window_size window_size = 10000 current_size = @options['esoffset'].to_i + @options['eslimit'].to_i - @options['eslimit'] = window_size - @options['esoffset'].to_i if current_size > window_size + @options['eslimit'] = window_size - @options['esoffset'].to_i if current_size > window_size end def adjust_project_filter diff --git a/lib/list_export.rb b/lib/list_export.rb new file mode 100644 index 0000000000..51ec959d60 --- /dev/null +++ b/lib/list_export.rb @@ -0,0 +1,67 @@ +class ListExport + TYPES = [:media, :feed, :fact_check, :explainer] + + def initialize(type, query, team_id) + @type = type + @query = query + @parsed_query = JSON.parse(@query) + @team_id = team_id + @team = Team.find(team_id) + @feed = Feed.where(id: @parsed_query['feed_id'], team_id: @team_id).last if type == :feed + raise "Invalid export type '#{type}'. Should be one of: #{TYPES}" unless TYPES.include?(type) + end + + def number_of_rows + case @type + when :media + CheckSearch.new(@query, nil, @team_id).number_of_results + when :feed + @feed.clusters_count(@parsed_query) + when :fact_check + @team.filtered_fact_checks(@parsed_query).count + when :explainer + @team.filtered_explainers(@parsed_query).count + end + end + + def generate_csv_and_send_email_in_background(user) + ListExport.delay.generate_csv_and_send_email(self, user.id) + end + + def generate_csv_and_send_email(user) + # Convert to CSV + csv_string = CSV.generate do |csv| + self.export_data.each do |row| + csv << row + end + end + + # Save to S3 + csv_file_url = CheckS3.write_presigned("export/#{@type}/#{@team_id}/#{Time.now.to_i}/#{Digest::MD5.hexdigest(@query)}.csv", 'text/csv', csv_string, CheckConfig.get('export_csv_expire', 7.days.to_i, :integer)) + + # Send to e-mail + ExportListMailer.delay.send_csv(csv_file_url, user) + + # Return path to CSV + csv_file_url + end + + def self.generate_csv_and_send_email(export, user_id) + export.generate_csv_and_send_email(User.find(user_id)) + end + + private + + def export_data + case @type + when :media + CheckSearch.get_exported_data(@query, @team_id) + when :feed + @feed.get_exported_data(@parsed_query) + when :fact_check + FactCheck.get_exported_data(@parsed_query, @team) + when :explainer + Explainer.get_exported_data(@parsed_query, @team) + end + end +end diff --git a/lib/relay.idl b/lib/relay.idl index 4c7445b1ae..e0e7bc11e7 100644 --- a/lib/relay.idl +++ b/lib/relay.idl @@ -8357,6 +8357,29 @@ type ExplainerItemEdge { node: ExplainerItem } +""" +Autogenerated input type of ExportList +""" +input ExportListInput { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + query: String! + type: String! +} + +""" +Autogenerated return type of ExportList +""" +type ExportListPayload { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + success: Boolean +} + """ Autogenerated input type of ExtractText """ @@ -9917,6 +9940,12 @@ type MutationType { """ input: DuplicateTeamMutationInput! ): DuplicateTeamMutationPayload + exportList( + """ + Parameters for ExportList + """ + input: ExportListInput! + ): ExportListPayload extractText( """ Parameters for ExtractText diff --git a/public/relay.json b/public/relay.json index 10d08ecdd4..3cc4a0e499 100644 --- a/public/relay.json +++ b/public/relay.json @@ -45264,6 +45264,102 @@ "enumValues": null, "possibleTypes": null }, + { + "kind": "INPUT_OBJECT", + "name": "ExportListInput", + "description": "Autogenerated input type of ExportList", + "fields": null, + "inputFields": [ + { + "name": "query", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "type", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "ExportListPayload", + "description": "Autogenerated return type of ExportList", + "fields": [ + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "success", + "description": null, + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [ + + ], + "enumValues": null, + "possibleTypes": null + }, { "kind": "INPUT_OBJECT", "name": "ExtractTextInput", @@ -53582,6 +53678,35 @@ "isDeprecated": false, "deprecationReason": null }, + { + "name": "exportList", + "description": null, + "args": [ + { + "name": "input", + "description": "Parameters for ExportList", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "ExportListInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "ExportListPayload", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, { "name": "extractText", "description": null, diff --git a/test/controllers/graphql_controller_11_test.rb b/test/controllers/graphql_controller_11_test.rb index af6670ab50..2cad8e2620 100644 --- a/test/controllers/graphql_controller_11_test.rb +++ b/test/controllers/graphql_controller_11_test.rb @@ -132,10 +132,10 @@ def teardown post :create, params: { query: query } assert_response :success response = JSON.parse(@response.body)['data']['me'] - data = response['accessible_teams']['edges'] + data = response['accessible_teams']['edges'].collect{ |edge| edge['node']['dbid'] }.sort assert_equal 2, data.size - assert_equal team1.id, data[0]['node']['dbid'] - assert_equal team2.id, data[1]['node']['dbid'] + assert_equal team1.id, data[0] + assert_equal team2.id, data[1] assert_equal 2, response['accessible_teams_count'] end @@ -159,4 +159,45 @@ def teardown assert_equal team1.id, data[0]['node']['dbid'] assert_equal 1, response['accessible_teams_count'] end + + test "should export list if it's a workspace admin and number of results is not over the limit" do + Sidekiq::Testing.inline! + u = create_user + t = create_team + create_team_user team: t, user: u, role: 'admin' + authenticate_with_user(u) + + query = "mutation { exportList(input: { query: \"{}\", type: \"media\" }) { success } }" + post :create, params: { query: query, team: t.slug } + assert_response :success + assert JSON.parse(@response.body)['data']['exportList']['success'] + end + + test "should not export list if it's not a workspace admin" do + Sidekiq::Testing.inline! + u = create_user + t = create_team + create_team_user team: t, user: u, role: 'editor' + authenticate_with_user(u) + + query = "mutation { exportList(input: { query: \"{}\", type: \"media\" }) { success } }" + post :create, params: { query: query, team: t.slug } + assert_response :success + assert !JSON.parse(@response.body)['data']['exportList']['success'] + end + + test "should not export list if it's over the limit" do + Sidekiq::Testing.inline! + u = create_user + t = create_team + create_team_user team: t, user: u, role: 'admin' + authenticate_with_user(u) + + stub_configs({ 'export_csv_maximum_number_of_results' => -1 }) do + query = "mutation { exportList(input: { query: \"{}\", type: \"media\" }) { success } }" + post :create, params: { query: query, team: t.slug } + assert_response :success + assert !JSON.parse(@response.body)['data']['exportList']['success'] + end + end end diff --git a/test/lib/check_s3_test.rb b/test/lib/check_s3_test.rb index d313f1b3df..1dc71a5c02 100644 --- a/test/lib/check_s3_test.rb +++ b/test/lib/check_s3_test.rb @@ -1,6 +1,12 @@ require_relative '../test_helper' class CheckS3Test < ActiveSupport::TestCase + def setup + end + + def teardown + end + test "should return resource" do assert_kind_of Aws::S3::Resource, CheckS3.resource end diff --git a/test/lib/list_export_test.rb b/test/lib/list_export_test.rb new file mode 100644 index 0000000000..668797d6e6 --- /dev/null +++ b/test/lib/list_export_test.rb @@ -0,0 +1,86 @@ +require_relative '../test_helper' + +class ListExportTest < ActiveSupport::TestCase + def setup + end + + def teardown + end + + test "should expire the export" do + t = create_team + create_team_task team_id: t.id, fieldset: 'tasks' + pm = create_project_media team: t + + stub_configs({ 'export_csv_expire' => 2 }) do + # Generate a CSV with the two exported items + export = ListExport.new(:media, '{}', t.id) + csv_url = export.generate_csv_and_send_email(create_user) + response = Net::HTTP.get_response(URI(csv_url)) + assert_equal 200, response.code.to_i + + # Make sure it expires after 2 seconds + sleep 3 # Just to be safe + response = Net::HTTP.get_response(URI(csv_url)) + assert_equal 403, response.code.to_i + end + end + + test "should export media CSV" do + t = create_team + create_team_task team_id: t.id, fieldset: 'tasks' + 2.times { create_project_media team: t } + + export = ListExport.new(:media, '{}', t.id) + csv_url = export.generate_csv_and_send_email(create_user) + response = Net::HTTP.get_response(URI(csv_url)) + assert_equal 200, response.code.to_i + csv_content = CSV.parse(response.body, headers: true) + assert_equal 2, csv_content.size + assert_equal 2, export.number_of_rows + end + + test "should export feed CSV" do + t = create_team + f = create_feed team: t + 2.times { f.clusters << create_cluster } + + export = ListExport.new(:feed, { feed_id: f.id }.to_json, t.id) + csv_url = export.generate_csv_and_send_email(create_user) + response = Net::HTTP.get_response(URI(csv_url)) + assert_equal 200, response.code.to_i + csv_content = CSV.parse(response.body, headers: true) + assert_equal 2, csv_content.size + assert_equal 2, export.number_of_rows + end + + test "should export fact-checks CSV" do + t = create_team + 2.times do + pm = create_project_media team: t + cd = create_claim_description project_media: pm + create_fact_check claim_description: cd + end + + export = ListExport.new(:fact_check, '{}', t.id) + csv_url = export.generate_csv_and_send_email(create_user) + response = Net::HTTP.get_response(URI(csv_url)) + assert_equal 200, response.code.to_i + csv_content = CSV.parse(response.body, headers: true) + assert_equal 2, csv_content.size + assert_equal 2, export.number_of_rows + end + + test "should export explainers CSV" do + t = create_team + 2.times { create_explainer team: t } + + export = ListExport.new(:explainer, '{}', t.id) + csv_url = export.generate_csv_and_send_email(create_user) + response = Net::HTTP.get_response(URI(csv_url)) + assert_equal 200, response.code.to_i + csv_content = CSV.parse(response.body, headers: true) + assert_equal 2, csv_content.size + assert_equal 2, export.number_of_rows + end +end From 72503a0abebc22cafdad7577a65a1afbad9afa52 Mon Sep 17 00:00:00 2001 From: Chinelo Obitube <74656858+chinelo-obitube@users.noreply.github.com> Date: Tue, 27 Aug 2024 16:50:37 +0200 Subject: [PATCH 06/14] run tests(github actions) on other branches (#2007) Co-authored-by: chinelo-obitube --- .github/workflows/ci-tests.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/ci-tests.yml b/.github/workflows/ci-tests.yml index 9a35e688f9..3c8e3b8c38 100644 --- a/.github/workflows/ci-tests.yml +++ b/.github/workflows/ci-tests.yml @@ -10,6 +10,8 @@ on: pull_request: branches: - develop + - epic* + - cv2* env: CC_TEST_REPORTER_ID: "${{ secrets.CC_TEST_REPORTER_ID }}" From c1c9b20ab1efd1ad592844b459db6729ea6be3d6 Mon Sep 17 00:00:00 2001 From: Caio Almeida <117518+caiosba@users.noreply.github.com> Date: Tue, 27 Aug 2024 12:04:57 -0300 Subject: [PATCH 07/14] Adjust Smooch Bot provider when tipline is running both CAPI and Smooch. (#2004) Fixes CV2-5127. --- app/models/concerns/smooch_resend.rb | 2 ++ app/workers/tipline_newsletter_worker.rb | 1 + 2 files changed, 3 insertions(+) diff --git a/app/models/concerns/smooch_resend.rb b/app/models/concerns/smooch_resend.rb index 883ed49cd1..455ce33ac8 100644 --- a/app/models/concerns/smooch_resend.rb +++ b/app/models/concerns/smooch_resend.rb @@ -159,6 +159,7 @@ def message_tags_payload(text, image = nil) def resend_facebook_messenger_message_after_window(message, original) original = JSON.parse(original) unless original.blank? uid = message['appUser']['_id'] + RequestStore.store[:smooch_bot_provider] = 'ZENDESK' return self.resend_facebook_messenger_report_after_window(message, original) if original&.dig('fallback_template') =~ /report/ @@ -184,6 +185,7 @@ def resend_facebook_messenger_message_after_window(message, original) end def resend_facebook_messenger_report_after_window(message, original) + RequestStore.store[:smooch_bot_provider] = 'ZENDESK' pm = ProjectMedia.where(id: original['project_media_id']).last report = self.get_report_data_to_be_resent(message, original) unless report.nil? diff --git a/app/workers/tipline_newsletter_worker.rb b/app/workers/tipline_newsletter_worker.rb index 45adefa1ae..a0458ded23 100644 --- a/app/workers/tipline_newsletter_worker.rb +++ b/app/workers/tipline_newsletter_worker.rb @@ -39,6 +39,7 @@ def perform(team_id, language, job_created_at = 0) begin RequestStore.store[:smooch_bot_platform] = ts.platform Bot::Smooch.get_installation('team_bot_installation_id', tbi.id) { |i| i.id == tbi.id } + RequestStore.store[:smooch_bot_provider] = 'ZENDESK' if ts.platform != 'WhatsApp' # Adjustment for tiplines running CAPI and Smooch at the same time response = (ts.platform == 'WhatsApp' ? Bot::Smooch.send_message_to_user(ts.uid, newsletter.format_as_template_message, {}, false, true, 'newsletter') : Bot::Smooch.send_message_to_user(ts.uid, *newsletter.format_as_tipline_message)) From b5e6b5dcc43373900f0d7f4e966c015d35ae9a3e Mon Sep 17 00:00:00 2001 From: Caio Almeida <117518+caiosba@users.noreply.github.com> Date: Sat, 24 Aug 2024 08:43:55 -0300 Subject: [PATCH 08/14] Export lists (#1992) Allow lists (core, custom, search results, articles, filtered or not) to be exported to a CSV file that is sent to e-mail. - [x] Add an `exportList` GraphQL mutation - [x] Implement a generic export class that supports media, articles and feeds - [x] Validate maximum number of results (which is a global configuration key) - [x] Validate permission - [x] Create Sidekiq job to export results - [x] Create a CSV for the export - [x] Save CSV in S3 using a pre-signed URL that expires after X days ("X" is a global configuration key) - [x] Add support to MailCatcher - [x] Send CSV by e-mail - [x] Automated tests - [x] Make sure it works for articles as well - [x] Make sure it works for shared feeds as well References: CV2-5067 and CV2-4979. --- app/graph/mutations/export_mutations.rb | 24 ++++ app/graph/types/mutation_type.rb | 2 + app/lib/check_config.rb | 1 + app/mailers/export_list_mailer.rb | 13 ++ app/models/ability.rb | 2 +- app/models/explainer.rb | 8 ++ app/models/fact_check.rb | 8 ++ app/models/feed.rb | 8 ++ .../export_list_mailer/send_csv.html.erb | 130 ++++++++++++++++++ .../export_list_mailer/send_csv.text.erb | 14 ++ config/config.yml.example | 11 +- config/environments/development.rb | 8 ++ config/initializers/plugins.rb | 2 +- config/locales/en.yml | 6 + lib/check_s3.rb | 9 ++ lib/check_search.rb | 45 +++++- lib/list_export.rb | 67 +++++++++ lib/relay.idl | 29 ++++ public/relay.json | 125 +++++++++++++++++ .../controllers/graphql_controller_11_test.rb | 47 ++++++- test/lib/check_s3_test.rb | 6 + test/lib/list_export_test.rb | 86 ++++++++++++ 22 files changed, 643 insertions(+), 8 deletions(-) create mode 100644 app/graph/mutations/export_mutations.rb create mode 100644 app/mailers/export_list_mailer.rb create mode 100644 app/views/export_list_mailer/send_csv.html.erb create mode 100644 app/views/export_list_mailer/send_csv.text.erb create mode 100644 lib/list_export.rb create mode 100644 test/lib/list_export_test.rb diff --git a/app/graph/mutations/export_mutations.rb b/app/graph/mutations/export_mutations.rb new file mode 100644 index 0000000000..a3dde76a1b --- /dev/null +++ b/app/graph/mutations/export_mutations.rb @@ -0,0 +1,24 @@ +module ExportMutations + class ExportList < Mutations::BaseMutation + argument :query, GraphQL::Types::String, required: true # JSON + argument :type, GraphQL::Types::String, required: true # 'media', 'feed', 'fact-check' or 'explainer' + + field :success, GraphQL::Types::Boolean, null: true + + def resolve(query:, type:) + ability = context[:ability] + team = Team.find_if_can(Team.current.id, ability) + if ability.cannot?(:export_list, team) + { success: false } + else + export = ListExport.new(type.to_sym, query, team.id) + if export.number_of_rows > CheckConfig.get(:export_csv_maximum_number_of_results, 10000, :integer) + { success: false } + else + export.generate_csv_and_send_email_in_background(User.current) + { success: true } + end + end + end + end +end diff --git a/app/graph/types/mutation_type.rb b/app/graph/types/mutation_type.rb index a87ea5528c..fbf56730b4 100644 --- a/app/graph/types/mutation_type.rb +++ b/app/graph/types/mutation_type.rb @@ -152,4 +152,6 @@ class MutationType < BaseObject field :createExplainerItem, mutation: ExplainerItemMutations::Create field :destroyExplainerItem, mutation: ExplainerItemMutations::Destroy + + field :exportList, mutation: ExportMutations::ExportList end diff --git a/app/lib/check_config.rb b/app/lib/check_config.rb index fe34a355c9..764f747063 100644 --- a/app/lib/check_config.rb +++ b/app/lib/check_config.rb @@ -3,6 +3,7 @@ class CheckConfig def self.get(key, default = nil, type = nil) + key = key.to_s value = ENV[key] value ||= CONFIG[key] if CONFIG.has_key?(key) return default if value.nil? diff --git a/app/mailers/export_list_mailer.rb b/app/mailers/export_list_mailer.rb new file mode 100644 index 0000000000..c9fed76b02 --- /dev/null +++ b/app/mailers/export_list_mailer.rb @@ -0,0 +1,13 @@ +class ExportListMailer < ApplicationMailer + layout nil + + def send_csv(csv_file_url, user) + @csv_file_url = csv_file_url + @user = user + expire_in = Time.now.to_i + CheckConfig.get('export_csv_expire', 7.days.to_i, :integer) + @expire_in = I18n.l(Time.at(expire_in), format: :email) + subject = I18n.t('mails_notifications.export_list.subject') + Rails.logger.info "Sending export e-mail to #{@user.email}" + mail(to: @user.email, email_type: 'export_list', subject: subject) + end +end diff --git a/app/models/ability.rb b/app/models/ability.rb index 42d45f7fa6..1db39e2c31 100644 --- a/app/models/ability.rb +++ b/app/models/ability.rb @@ -57,7 +57,7 @@ def admin_perms can :destroy, Team, :id => @context_team.id can :create, TeamUser, :team_id => @context_team.id, role: ['admin'] can [:update, :destroy], TeamUser, team_id: @context_team.id - can :duplicate, Team, :id => @context_team.id + can [:duplicate, :export_list], Team, :id => @context_team.id can :set_privacy, Project, :team_id => @context_team.id can :read_feed_invitations, Feed, :team_id => @context_team.id can :destroy, Feed, :team_id => @context_team.id diff --git a/app/models/explainer.rb b/app/models/explainer.rb index f1599f4617..35b1bf2887 100644 --- a/app/models/explainer.rb +++ b/app/models/explainer.rb @@ -48,6 +48,14 @@ def update_paragraphs_in_alegre self.class.delay_for(5.seconds).update_paragraphs_in_alegre(self.id, previous_paragraphs_count, Time.now.to_f) end + def self.get_exported_data(query, team) + data = [['ID', 'Title', 'Description', 'URL', 'Language']] + team.filtered_explainers(query).find_each do |exp| + data << [exp.id, exp.title, exp.description, exp.url, exp.language] + end + data + end + def self.update_paragraphs_in_alegre(id, previous_paragraphs_count, timestamp) explainer = Explainer.find(id) diff --git a/app/models/fact_check.rb b/app/models/fact_check.rb index 5d42782496..5830494615 100644 --- a/app/models/fact_check.rb +++ b/app/models/fact_check.rb @@ -47,6 +47,14 @@ def update_item_status end end + def self.get_exported_data(query, team) + data = [['ID', 'Title', 'Summary', 'URL', 'Language', 'Report Status', 'Imported?']] + team.filtered_fact_checks(query).find_each do |fc| + data << [fc.id, fc.title, fc.summary, fc.url, fc.language, fc.report_status, fc.imported.to_s] + end + data + end + private def set_language diff --git a/app/models/feed.rb b/app/models/feed.rb index 05a652024f..e90c2c2d84 100755 --- a/app/models/feed.rb +++ b/app/models/feed.rb @@ -172,6 +172,14 @@ def saved_search_was SavedSearch.find_by_id(self.saved_search_id_before_last_save) end + def get_exported_data(filters) + data = [['Title', 'Number of media', 'Number of requests', 'Number of fact-checks']] + self.filtered_clusters(filters).find_each do |cluster| + data << [cluster.title, cluster.media_count, cluster.requests_count, cluster.fact_checks_count] + end + data + end + # This takes some time to run because it involves external HTTP requests and writes to the database: # 1) If the query contains a media URL, it will be downloaded... if it contains some other URL, it will be sent to Pender # 2) Requests will be made to Alegre in order to index the request media and to look for similar requests diff --git a/app/views/export_list_mailer/send_csv.html.erb b/app/views/export_list_mailer/send_csv.html.erb new file mode 100644 index 0000000000..081169fd8c --- /dev/null +++ b/app/views/export_list_mailer/send_csv.html.erb @@ -0,0 +1,130 @@ +<%= render "shared/header" %> + + + + + +
+ + + + +
 
+
+ + +
+
+
+ <%= I18n.t(:"mails_notifications.export_list.hello", name: @user.name) %> +
+ + + + +
 
+
+ <%= I18n.t("mails_notifications.export_list.subject") %> +
+ + + + +
 
+
+
+ <%= I18n.t(:"mails_notifications.export_list.body") %> +
+
+
+ + + + + +
 
+ + + + +
+ + + + + +
+ + + + + +
+ + <%= + link_to(I18n.t('mails_notifications.export_list.button_label'), + @csv_file_url, + :style => "text-decoration: none !important;color: #fff !important;" + ) + %> + + + <%= image_tag("https://images.ctfassets.net/g118h5yoccvd/#{@direction[:arrow]}", width: "7", alt: "arrow-icon", style: "-ms-interpolation-mode: bicubic; border: 0 none; height: auto; line-height: 100%; outline: none; text-decoration: none;") %> +
+
+ + + + + +
 
+ +
+
+ <%= I18n.t(:"mails_notifications.export_list.footer", date: @expire_in) %> +
+
+
+ + + + +
 
+ + +<%= render "shared/footer" %> diff --git a/app/views/export_list_mailer/send_csv.text.erb b/app/views/export_list_mailer/send_csv.text.erb new file mode 100644 index 0000000000..428c639142 --- /dev/null +++ b/app/views/export_list_mailer/send_csv.text.erb @@ -0,0 +1,14 @@ +<%= I18n.t('mails_notifications.export_list.hello', name: @user.name) %> + +<%= I18n.t('mails_notifications.export_list.subject') %> + +<%= I18n.t('mails_notifications.export_list.body') %> + +<%= I18n.t('mails_notifications.export_list.button_label') %>: <%= @csv_file_url %> + +<%= I18n.t('mails_notifications.export_list.footer', date: @expire_in ) %> + +... + +<%= strip_tags I18n.t("mails_notifications.copyright_html", app_name: CheckConfig.get('app_name')) %> +https://meedan.com diff --git a/config/config.yml.example b/config/config.yml.example index e14ec0ee77..44749ca977 100644 --- a/config/config.yml.example +++ b/config/config.yml.example @@ -160,6 +160,7 @@ development: &default smtp_user: # '' smtp_pass: # '' smtp_default_url_host: 'http://localhost:3333' # Used to construct URLs for links in email + smtp_mailcatcher_host: # 'host.docker.internal' # Pusher notification service https://pusher.com/channels # @@ -262,7 +263,7 @@ development: &default otel_traces_sampler: otel_custom_sampling_rate: - # Rate limits for tiplines + # Limits # # OPTIONAL # When not set, default values are used. @@ -270,12 +271,18 @@ development: &default tipline_user_max_messages_per_day: 1500 nlu_global_rate_limit: 100 nlu_user_rate_limit: 30 - devise_maximum_attempts: 5 devise_unlock_accounts_after: 1 login_rate_limit: 10 api_rate_limit: 100 + export_csv_maximum_number_of_results: 10000 + export_csv_expire: 604800 # Seconds: Default is 7 days + # Session + # + # OPTIONAL + # When not set, default values are used. + # session_store_key: '_checkdesk_session_dev' session_store_domain: 'localhost' test: diff --git a/config/environments/development.rb b/config/environments/development.rb index b8924f73e8..3f54f6587f 100644 --- a/config/environments/development.rb +++ b/config/environments/development.rb @@ -83,4 +83,12 @@ else puts '[WARNING] config.hosts not provided. Only requests from localhost are allowed. To change, update `whitelisted_hosts` in config.yml' end + + mailcatcher_host = ENV['smtp_mailcatcher_host'] || cfg['smtp_mailcatcher_host'] + unless mailcatcher_host.blank? + config.action_mailer.smtp_settings = { + address: mailcatcher_host, + port: 1025 + } + end end diff --git a/config/initializers/plugins.rb b/config/initializers/plugins.rb index 056a5b61a5..b928f936ff 100644 --- a/config/initializers/plugins.rb +++ b/config/initializers/plugins.rb @@ -1,2 +1,2 @@ # Load classes on boot, in production, that otherwise wouldn't be auto-loaded by default -CcDeville && Bot::Keep && Workflow::Workflow.workflows && CheckS3 && Bot::Tagger && Bot::Fetch && Bot::Smooch && Bot::Slack && Bot::Alegre && CheckChannels && RssFeed && UrlRewriter && ClusterTeam +CcDeville && Bot::Keep && Workflow::Workflow.workflows && CheckS3 && Bot::Tagger && Bot::Fetch && Bot::Smooch && Bot::Slack && Bot::Alegre && CheckChannels && RssFeed && UrlRewriter && ClusterTeam && ListExport diff --git a/config/locales/en.yml b/config/locales/en.yml index aa441378fc..e947f62ab1 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -476,6 +476,12 @@ en: constitutes acceptance of our updated Terms of Service. term_button: Terms of Service more_info: This is a one-time required legal notice sent to all Check users, even those who have unsubscribed by optional announcements. + export_list: + hello: Hello %{name} + subject: Check Data Export + body: Your requested Check data export is available to download. + button_label: Download Export + footer: This download link will expire on %{date}. mail_security: device_subject: 'Security alert: New login to %{app_name} from %{browser} on %{platform}' ip_subject: 'Security alert: New or unusual %{app_name} login' diff --git a/lib/check_s3.rb b/lib/check_s3.rb index af6d3c8288..7989cefbb4 100644 --- a/lib/check_s3.rb +++ b/lib/check_s3.rb @@ -65,4 +65,13 @@ def self.delete(*paths) client = Aws::S3::Client.new client.delete_objects(bucket: CheckConfig.get('storage_bucket'), delete: { objects: objects }) end + + def self.write_presigned(path, content_type, content, expires_in) + self.write(path, content_type, content) + bucket = CheckConfig.get('storage_bucket') + client = Aws::S3::Client.new + s3 = Aws::S3::Resource.new(client: client) + obj = s3.bucket(bucket).object(path) + obj.presigned_url(:get, expires_in: expires_in) + end end diff --git a/lib/check_search.rb b/lib/check_search.rb index cbcd41cea6..d8707e31a2 100644 --- a/lib/check_search.rb +++ b/lib/check_search.rb @@ -60,6 +60,10 @@ def initialize(options, file = nil, team_id = Team.current&.id) 'fact_check_published_on' => 'fact_check_published_on' } + def set_option(key, value) + @options[key] = value + end + def team_condition(team_id = nil) if feed_query? feed_teams = @options['feed_team_ids'].is_a?(Array) ? (@feed.team_ids & @options['feed_team_ids']) : @feed.team_ids @@ -329,12 +333,51 @@ def medias_get_search_result(query) @options['es_id'] ? $repository.find([@options['es_id']]).compact : $repository.search(query: query, collapse: collapse, sort: sort, size: @options['eslimit'], from: @options['esoffset']).results end + def self.get_exported_data(query, team_id) + team = Team.find(team_id) + search = CheckSearch.new(query, nil, team_id) + + # Prepare the export + data = [] + header = ['Claim', 'Item page URL', 'Status', 'Created by', 'Submitted at', 'Published at', 'Number of media', 'Tags'] + fields = team.team_tasks.sort + fields.each { |tt| header << tt.label } + data << header + + # No pagination for the export + search.set_option('esoffset', 0) + search.set_option('eslimit', CheckConfig.get(:export_csv_maximum_number_of_results, 10000, :integer)) + + # Iterate through each result and generate an output row for the CSV + search.medias.find_each do |pm| + row = [ + pm.claim_description&.description, + pm.full_url, + pm.status_i18n, + pm.author_name.to_s.gsub(/ \[.*\]$/, ''), + pm.created_at.strftime("%Y-%m-%d %H:%M:%S"), + pm.published_at&.strftime("%Y-%m-%d %H:%M:%S"), + pm.linked_items_count(true), + pm.tags_as_sentence(true) + ] + annotations = pm.get_annotations('task').map(&:load) + fields.each do |field| + annotation = annotations.find { |a| a.team_task_id == field.id } + answer = (annotation ? (begin annotation.first_response_obj.file_data[:file_urls].join("\n") rescue annotation.first_response.to_s end) : '') + answer = begin JSON.parse(answer).collect{ |x| x['url'] }.join(', ') rescue answer end + row << answer + end + data << row + end + data + end + private def adjust_es_window_size window_size = 10000 current_size = @options['esoffset'].to_i + @options['eslimit'].to_i - @options['eslimit'] = window_size - @options['esoffset'].to_i if current_size > window_size + @options['eslimit'] = window_size - @options['esoffset'].to_i if current_size > window_size end def adjust_project_filter diff --git a/lib/list_export.rb b/lib/list_export.rb new file mode 100644 index 0000000000..51ec959d60 --- /dev/null +++ b/lib/list_export.rb @@ -0,0 +1,67 @@ +class ListExport + TYPES = [:media, :feed, :fact_check, :explainer] + + def initialize(type, query, team_id) + @type = type + @query = query + @parsed_query = JSON.parse(@query) + @team_id = team_id + @team = Team.find(team_id) + @feed = Feed.where(id: @parsed_query['feed_id'], team_id: @team_id).last if type == :feed + raise "Invalid export type '#{type}'. Should be one of: #{TYPES}" unless TYPES.include?(type) + end + + def number_of_rows + case @type + when :media + CheckSearch.new(@query, nil, @team_id).number_of_results + when :feed + @feed.clusters_count(@parsed_query) + when :fact_check + @team.filtered_fact_checks(@parsed_query).count + when :explainer + @team.filtered_explainers(@parsed_query).count + end + end + + def generate_csv_and_send_email_in_background(user) + ListExport.delay.generate_csv_and_send_email(self, user.id) + end + + def generate_csv_and_send_email(user) + # Convert to CSV + csv_string = CSV.generate do |csv| + self.export_data.each do |row| + csv << row + end + end + + # Save to S3 + csv_file_url = CheckS3.write_presigned("export/#{@type}/#{@team_id}/#{Time.now.to_i}/#{Digest::MD5.hexdigest(@query)}.csv", 'text/csv', csv_string, CheckConfig.get('export_csv_expire', 7.days.to_i, :integer)) + + # Send to e-mail + ExportListMailer.delay.send_csv(csv_file_url, user) + + # Return path to CSV + csv_file_url + end + + def self.generate_csv_and_send_email(export, user_id) + export.generate_csv_and_send_email(User.find(user_id)) + end + + private + + def export_data + case @type + when :media + CheckSearch.get_exported_data(@query, @team_id) + when :feed + @feed.get_exported_data(@parsed_query) + when :fact_check + FactCheck.get_exported_data(@parsed_query, @team) + when :explainer + Explainer.get_exported_data(@parsed_query, @team) + end + end +end diff --git a/lib/relay.idl b/lib/relay.idl index 4c7445b1ae..e0e7bc11e7 100644 --- a/lib/relay.idl +++ b/lib/relay.idl @@ -8357,6 +8357,29 @@ type ExplainerItemEdge { node: ExplainerItem } +""" +Autogenerated input type of ExportList +""" +input ExportListInput { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + query: String! + type: String! +} + +""" +Autogenerated return type of ExportList +""" +type ExportListPayload { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + success: Boolean +} + """ Autogenerated input type of ExtractText """ @@ -9917,6 +9940,12 @@ type MutationType { """ input: DuplicateTeamMutationInput! ): DuplicateTeamMutationPayload + exportList( + """ + Parameters for ExportList + """ + input: ExportListInput! + ): ExportListPayload extractText( """ Parameters for ExtractText diff --git a/public/relay.json b/public/relay.json index 10d08ecdd4..3cc4a0e499 100644 --- a/public/relay.json +++ b/public/relay.json @@ -45264,6 +45264,102 @@ "enumValues": null, "possibleTypes": null }, + { + "kind": "INPUT_OBJECT", + "name": "ExportListInput", + "description": "Autogenerated input type of ExportList", + "fields": null, + "inputFields": [ + { + "name": "query", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "type", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "ExportListPayload", + "description": "Autogenerated return type of ExportList", + "fields": [ + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "success", + "description": null, + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [ + + ], + "enumValues": null, + "possibleTypes": null + }, { "kind": "INPUT_OBJECT", "name": "ExtractTextInput", @@ -53582,6 +53678,35 @@ "isDeprecated": false, "deprecationReason": null }, + { + "name": "exportList", + "description": null, + "args": [ + { + "name": "input", + "description": "Parameters for ExportList", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "ExportListInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "ExportListPayload", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, { "name": "extractText", "description": null, diff --git a/test/controllers/graphql_controller_11_test.rb b/test/controllers/graphql_controller_11_test.rb index af6670ab50..2cad8e2620 100644 --- a/test/controllers/graphql_controller_11_test.rb +++ b/test/controllers/graphql_controller_11_test.rb @@ -132,10 +132,10 @@ def teardown post :create, params: { query: query } assert_response :success response = JSON.parse(@response.body)['data']['me'] - data = response['accessible_teams']['edges'] + data = response['accessible_teams']['edges'].collect{ |edge| edge['node']['dbid'] }.sort assert_equal 2, data.size - assert_equal team1.id, data[0]['node']['dbid'] - assert_equal team2.id, data[1]['node']['dbid'] + assert_equal team1.id, data[0] + assert_equal team2.id, data[1] assert_equal 2, response['accessible_teams_count'] end @@ -159,4 +159,45 @@ def teardown assert_equal team1.id, data[0]['node']['dbid'] assert_equal 1, response['accessible_teams_count'] end + + test "should export list if it's a workspace admin and number of results is not over the limit" do + Sidekiq::Testing.inline! + u = create_user + t = create_team + create_team_user team: t, user: u, role: 'admin' + authenticate_with_user(u) + + query = "mutation { exportList(input: { query: \"{}\", type: \"media\" }) { success } }" + post :create, params: { query: query, team: t.slug } + assert_response :success + assert JSON.parse(@response.body)['data']['exportList']['success'] + end + + test "should not export list if it's not a workspace admin" do + Sidekiq::Testing.inline! + u = create_user + t = create_team + create_team_user team: t, user: u, role: 'editor' + authenticate_with_user(u) + + query = "mutation { exportList(input: { query: \"{}\", type: \"media\" }) { success } }" + post :create, params: { query: query, team: t.slug } + assert_response :success + assert !JSON.parse(@response.body)['data']['exportList']['success'] + end + + test "should not export list if it's over the limit" do + Sidekiq::Testing.inline! + u = create_user + t = create_team + create_team_user team: t, user: u, role: 'admin' + authenticate_with_user(u) + + stub_configs({ 'export_csv_maximum_number_of_results' => -1 }) do + query = "mutation { exportList(input: { query: \"{}\", type: \"media\" }) { success } }" + post :create, params: { query: query, team: t.slug } + assert_response :success + assert !JSON.parse(@response.body)['data']['exportList']['success'] + end + end end diff --git a/test/lib/check_s3_test.rb b/test/lib/check_s3_test.rb index d313f1b3df..1dc71a5c02 100644 --- a/test/lib/check_s3_test.rb +++ b/test/lib/check_s3_test.rb @@ -1,6 +1,12 @@ require_relative '../test_helper' class CheckS3Test < ActiveSupport::TestCase + def setup + end + + def teardown + end + test "should return resource" do assert_kind_of Aws::S3::Resource, CheckS3.resource end diff --git a/test/lib/list_export_test.rb b/test/lib/list_export_test.rb new file mode 100644 index 0000000000..668797d6e6 --- /dev/null +++ b/test/lib/list_export_test.rb @@ -0,0 +1,86 @@ +require_relative '../test_helper' + +class ListExportTest < ActiveSupport::TestCase + def setup + end + + def teardown + end + + test "should expire the export" do + t = create_team + create_team_task team_id: t.id, fieldset: 'tasks' + pm = create_project_media team: t + + stub_configs({ 'export_csv_expire' => 2 }) do + # Generate a CSV with the two exported items + export = ListExport.new(:media, '{}', t.id) + csv_url = export.generate_csv_and_send_email(create_user) + response = Net::HTTP.get_response(URI(csv_url)) + assert_equal 200, response.code.to_i + + # Make sure it expires after 2 seconds + sleep 3 # Just to be safe + response = Net::HTTP.get_response(URI(csv_url)) + assert_equal 403, response.code.to_i + end + end + + test "should export media CSV" do + t = create_team + create_team_task team_id: t.id, fieldset: 'tasks' + 2.times { create_project_media team: t } + + export = ListExport.new(:media, '{}', t.id) + csv_url = export.generate_csv_and_send_email(create_user) + response = Net::HTTP.get_response(URI(csv_url)) + assert_equal 200, response.code.to_i + csv_content = CSV.parse(response.body, headers: true) + assert_equal 2, csv_content.size + assert_equal 2, export.number_of_rows + end + + test "should export feed CSV" do + t = create_team + f = create_feed team: t + 2.times { f.clusters << create_cluster } + + export = ListExport.new(:feed, { feed_id: f.id }.to_json, t.id) + csv_url = export.generate_csv_and_send_email(create_user) + response = Net::HTTP.get_response(URI(csv_url)) + assert_equal 200, response.code.to_i + csv_content = CSV.parse(response.body, headers: true) + assert_equal 2, csv_content.size + assert_equal 2, export.number_of_rows + end + + test "should export fact-checks CSV" do + t = create_team + 2.times do + pm = create_project_media team: t + cd = create_claim_description project_media: pm + create_fact_check claim_description: cd + end + + export = ListExport.new(:fact_check, '{}', t.id) + csv_url = export.generate_csv_and_send_email(create_user) + response = Net::HTTP.get_response(URI(csv_url)) + assert_equal 200, response.code.to_i + csv_content = CSV.parse(response.body, headers: true) + assert_equal 2, csv_content.size + assert_equal 2, export.number_of_rows + end + + test "should export explainers CSV" do + t = create_team + 2.times { create_explainer team: t } + + export = ListExport.new(:explainer, '{}', t.id) + csv_url = export.generate_csv_and_send_email(create_user) + response = Net::HTTP.get_response(URI(csv_url)) + assert_equal 200, response.code.to_i + csv_content = CSV.parse(response.body, headers: true) + assert_equal 2, csv_content.size + assert_equal 2, export.number_of_rows + end +end From b373201e454f6331b4beb883d53e90bb44e9c808 Mon Sep 17 00:00:00 2001 From: Chinelo Obitube <74656858+chinelo-obitube@users.noreply.github.com> Date: Tue, 27 Aug 2024 16:50:37 +0200 Subject: [PATCH 09/14] run tests(github actions) on other branches (#2007) Co-authored-by: chinelo-obitube --- .github/workflows/ci-tests.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/ci-tests.yml b/.github/workflows/ci-tests.yml index 9a35e688f9..3c8e3b8c38 100644 --- a/.github/workflows/ci-tests.yml +++ b/.github/workflows/ci-tests.yml @@ -10,6 +10,8 @@ on: pull_request: branches: - develop + - epic* + - cv2* env: CC_TEST_REPORTER_ID: "${{ secrets.CC_TEST_REPORTER_ID }}" From 90ab045b804c301c1f38b40e2d473a1bcb716f45 Mon Sep 17 00:00:00 2001 From: Caio Almeida <117518+caiosba@users.noreply.github.com> Date: Tue, 27 Aug 2024 12:04:57 -0300 Subject: [PATCH 10/14] Adjust Smooch Bot provider when tipline is running both CAPI and Smooch. (#2004) Fixes CV2-5127. --- app/models/concerns/smooch_resend.rb | 2 ++ app/workers/tipline_newsletter_worker.rb | 1 + 2 files changed, 3 insertions(+) diff --git a/app/models/concerns/smooch_resend.rb b/app/models/concerns/smooch_resend.rb index 883ed49cd1..455ce33ac8 100644 --- a/app/models/concerns/smooch_resend.rb +++ b/app/models/concerns/smooch_resend.rb @@ -159,6 +159,7 @@ def message_tags_payload(text, image = nil) def resend_facebook_messenger_message_after_window(message, original) original = JSON.parse(original) unless original.blank? uid = message['appUser']['_id'] + RequestStore.store[:smooch_bot_provider] = 'ZENDESK' return self.resend_facebook_messenger_report_after_window(message, original) if original&.dig('fallback_template') =~ /report/ @@ -184,6 +185,7 @@ def resend_facebook_messenger_message_after_window(message, original) end def resend_facebook_messenger_report_after_window(message, original) + RequestStore.store[:smooch_bot_provider] = 'ZENDESK' pm = ProjectMedia.where(id: original['project_media_id']).last report = self.get_report_data_to_be_resent(message, original) unless report.nil? diff --git a/app/workers/tipline_newsletter_worker.rb b/app/workers/tipline_newsletter_worker.rb index 45adefa1ae..a0458ded23 100644 --- a/app/workers/tipline_newsletter_worker.rb +++ b/app/workers/tipline_newsletter_worker.rb @@ -39,6 +39,7 @@ def perform(team_id, language, job_created_at = 0) begin RequestStore.store[:smooch_bot_platform] = ts.platform Bot::Smooch.get_installation('team_bot_installation_id', tbi.id) { |i| i.id == tbi.id } + RequestStore.store[:smooch_bot_provider] = 'ZENDESK' if ts.platform != 'WhatsApp' # Adjustment for tiplines running CAPI and Smooch at the same time response = (ts.platform == 'WhatsApp' ? Bot::Smooch.send_message_to_user(ts.uid, newsletter.format_as_template_message, {}, false, true, 'newsletter') : Bot::Smooch.send_message_to_user(ts.uid, *newsletter.format_as_tipline_message)) From 5257311c918bcf3f2a1366a345c38cbf54eac9ef Mon Sep 17 00:00:00 2001 From: Chinelo Obitube <74656858+chinelo-obitube@users.noreply.github.com> Date: Tue, 27 Aug 2024 17:36:01 +0200 Subject: [PATCH 11/14] run tests(github actions) on other branches (#2008) Co-authored-by: chinelo-obitube --- .github/workflows/ci-tests.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci-tests.yml b/.github/workflows/ci-tests.yml index 3c8e3b8c38..d5126ae9ed 100644 --- a/.github/workflows/ci-tests.yml +++ b/.github/workflows/ci-tests.yml @@ -7,11 +7,12 @@ on: branches: - master - develop + - epic* + - cv2* pull_request: branches: - develop - - epic* - - cv2* + env: CC_TEST_REPORTER_ID: "${{ secrets.CC_TEST_REPORTER_ID }}" From 1d03987fd70aab5d958579196b07365be1eb64e0 Mon Sep 17 00:00:00 2001 From: Devin Gaffney Date: Tue, 27 Aug 2024 11:56:48 -0700 Subject: [PATCH 12/14] simplify calls and bring back in line with latest naming convention --- app/lib/smooch_nlu.rb | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/app/lib/smooch_nlu.rb b/app/lib/smooch_nlu.rb index 1b44904807..e1b8b0d337 100644 --- a/app/lib/smooch_nlu.rb +++ b/app/lib/smooch_nlu.rb @@ -44,14 +44,10 @@ def update_keywords(language, keywords, keyword, operation, doc_id, context) } if operation == 'add' && !keywords.include?(keyword) keywords << keyword - alegre_operation = 'post' - alegre_params = common_alegre_params.merge({ text: keyword, models: ALEGRE_MODELS_AND_THRESHOLDS.keys }) - Bot::Alegre.get_sync_raw_params(alegre_params, "text") if alegre_operation && alegre_params + Bot::Alegre.get_sync_with_params(common_alegre_params.merge({ text: keyword, models: ALEGRE_MODELS_AND_THRESHOLDS.keys }), "text") elsif operation == 'remove' keywords -= [keyword] - alegre_operation = 'delete' - alegre_params = common_alegre_params.merge({ quiet: true }) - Bot::Alegre.request_delete_from_raw(alegre_params, "text") if alegre_operation && alegre_params + Bot::Alegre.request_delete_from_raw(common_alegre_params.merge({ quiet: true }), "text") end keywords end @@ -91,7 +87,7 @@ def self.alegre_matches_from_message(message, language, context, alegre_result_k language: language, }.merge(context) } - response = Bot::Alegre.get_sync_raw_params(params, "text") + response = Bot::Alegre.get_sync_with_params(params, "text") # One approach would be to take the option that has the most matches # Unfortunately this approach is influenced by the number of keywords per option From 8001c3581f9f328f363c126c00c7ff3d20c945e7 Mon Sep 17 00:00:00 2001 From: Caio <117518+caiosba@users.noreply.github.com> Date: Tue, 27 Aug 2024 12:39:05 -0300 Subject: [PATCH 13/14] Updating Rubocop file --- .rubocop.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.rubocop.yml b/.rubocop.yml index d9bb8d820f..2ff1fbf74d 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -224,7 +224,7 @@ Metrics/CyclomaticComplexity: A complexity metric that is strongly correlated to the number of test cases needed to validate a method. Enabled: true - Max: 12 + Max: 13 Metrics/LineLength: Description: 'Limit lines to 80 characters.' From 2b8799953332a96e2fbc3fec35928792f55f25c3 Mon Sep 17 00:00:00 2001 From: Devin Gaffney Date: Tue, 27 Aug 2024 12:48:14 -0700 Subject: [PATCH 14/14] rebasing --- app/lib/smooch_nlu.rb | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/lib/smooch_nlu.rb b/app/lib/smooch_nlu.rb index e1b8b0d337..bf9566b817 100644 --- a/app/lib/smooch_nlu.rb +++ b/app/lib/smooch_nlu.rb @@ -87,7 +87,11 @@ def self.alegre_matches_from_message(message, language, context, alegre_result_k language: language, }.merge(context) } +<<<<<<< HEAD response = Bot::Alegre.get_sync_with_params(params, "text") +======= + response = Bot::Alegre.get_sync_raw_params(params, "text") +>>>>>>> 7c7cdd38d (rebase) # One approach would be to take the option that has the most matches # Unfortunately this approach is influenced by the number of keywords per option