diff --git a/app/graph/mutations/explainer_mutations.rb b/app/graph/mutations/explainer_mutations.rb index 03c217c2e2..2a9b865f0a 100644 --- a/app/graph/mutations/explainer_mutations.rb +++ b/app/graph/mutations/explainer_mutations.rb @@ -20,6 +20,8 @@ class Create < Mutations::CreateMutation class Update < Mutations::UpdateMutation include SharedCreateAndUpdateFields + + argument :trashed, GraphQL::Types::Boolean, required: false end class Destroy < Mutations::DestroyMutation; end diff --git a/app/graph/mutations/fact_check_mutations.rb b/app/graph/mutations/fact_check_mutations.rb index b6378feabd..ee9348a86f 100644 --- a/app/graph/mutations/fact_check_mutations.rb +++ b/app/graph/mutations/fact_check_mutations.rb @@ -26,6 +26,7 @@ class Update < Mutations::UpdateMutation argument :title, GraphQL::Types::String, required: false argument :summary, GraphQL::Types::String, required: false + argument :trashed, GraphQL::Types::Boolean, required: false end class Destroy < Mutations::DestroyMutation; end diff --git a/app/graph/types/explainer_type.rb b/app/graph/types/explainer_type.rb index c27bcf75c9..33d994c5bb 100644 --- a/app/graph/types/explainer_type.rb +++ b/app/graph/types/explainer_type.rb @@ -13,4 +13,5 @@ class ExplainerType < DefaultObject field :user, UserType, null: true field :team, PublicTeamType, null: true field :tags, [GraphQL::Types::String, null: true], null: true + field :trashed, GraphQL::Types::Boolean, null: true end diff --git a/app/graph/types/fact_check_type.rb b/app/graph/types/fact_check_type.rb index 0befd82e5b..eb0f62fd4f 100644 --- a/app/graph/types/fact_check_type.rb +++ b/app/graph/types/fact_check_type.rb @@ -14,4 +14,5 @@ class FactCheckType < DefaultObject field :rating, GraphQL::Types::String, null: true field :imported, GraphQL::Types::Boolean, null: true field :report_status, GraphQL::Types::String, null: true + field :trashed, GraphQL::Types::Boolean, null: true end diff --git a/app/graph/types/team_type.rb b/app/graph/types/team_type.rb index 33b0c691b0..f8d96424af 100644 --- a/app/graph/types/team_type.rb +++ b/app/graph/types/team_type.rb @@ -306,6 +306,7 @@ def tipline_messages(uid:) argument :rating, [GraphQL::Types::String, null: true], required: false, camelize: false argument :imported, GraphQL::Types::Boolean, required: false, camelize: false # Only for fact-checks argument :target_id, GraphQL::Types::Int, required: false, camelize: false # Exclude articles already applied to the `ProjectMedia` with this ID + argument :trashed, GraphQL::Types::Boolean, required: false, camelize: false, default_value: false end def articles(**args) @@ -336,6 +337,7 @@ def articles(**args) argument :rating, [GraphQL::Types::String, null: true], required: false, camelize: false argument :imported, GraphQL::Types::Boolean, required: false, camelize: false # Only for fact-checks argument :target_id, GraphQL::Types::Int, required: false, camelize: false # Exclude articles already applied to the `ProjectMedia` with this ID + argument :trashed, GraphQL::Types::Boolean, required: false, camelize: false, default_value: false end def articles_count(**args) diff --git a/app/models/claim_description.rb b/app/models/claim_description.rb index 64f8ff5391..7e2f8fed92 100644 --- a/app/models/claim_description.rb +++ b/app/models/claim_description.rb @@ -14,6 +14,7 @@ class ClaimDescription < ApplicationRecord validates_presence_of :team validates_uniqueness_of :project_media_id, allow_nil: true + validate :cant_apply_article_to_item_if_article_is_in_the_trash after_commit :update_fact_check, on: [:update] after_update :update_report_status after_update :replace_media, unless: proc { |cd| cd.disable_replace_media } @@ -115,4 +116,8 @@ def migrate_claim_and_fact_check_logs .where.not(event: 'create').update_all(associated_id: self.project_media_id) end end + + def cant_apply_article_to_item_if_article_is_in_the_trash + errors.add(:base, I18n.t(:cant_apply_article_to_item_if_article_is_in_the_trash)) if self.project_media && self.fact_check&.trashed + end end diff --git a/app/models/concerns/article.rb b/app/models/concerns/article.rb index e4b1ed48be..ee981866b5 100644 --- a/app/models/concerns/article.rb +++ b/app/models/concerns/article.rb @@ -14,6 +14,7 @@ module Article after_commit :update_elasticsearch_data, :send_to_alegre, :notify_bots, on: [:create, :update] after_commit :destroy_elasticsearch_data, on: :destroy after_save :create_tag_texts_if_needed + after_update :schedule_for_permanent_deletion_if_sent_to_trash, if: proc { |obj| obj.is_a?(FactCheck) || obj.is_a?(Explainer) } end def text_fields @@ -68,6 +69,13 @@ def create_tag_texts_if_needed self.class.delay.create_tag_texts_if_needed(self.team_id, self.tags) if self.respond_to?(:tags) && !self.tags.blank? end + def schedule_for_permanent_deletion_if_sent_to_trash + if self.trashed && !self.trashed_before_last_save + interval = CheckConfig.get('empty_trash_interval', 30, :integer) + self.class.delay_for(interval.days, { queue: 'trash', retry: 0 }).delete_permanently(self.id) + end + end + module ClassMethods def create_tag_texts_if_needed(team_id, tags) tags.to_a.map(&:strip).each do |tag| @@ -87,5 +95,13 @@ def send_to_alegre(id) ::Bot::Alegre.send_field_to_similarity_index(obj.project_media, field) end unless obj.nil? end + + def delete_permanently(id) + obj = self.find_by_id(id) + if obj && obj.trashed + obj.destroy! + obj.claim_description.destroy! if obj.is_a?(FactCheck) + end + end end end diff --git a/app/models/explainer.rb b/app/models/explainer.rb index 5b55a57694..5e2992bd8d 100644 --- a/app/models/explainer.rb +++ b/app/models/explainer.rb @@ -19,6 +19,7 @@ class Explainer < ApplicationRecord validate :language_in_allowed_values, unless: proc { |e| e.language.blank? } after_save :update_paragraphs_in_alegre + after_update :detach_explainer_if_trashed def notify_bots # Nothing to do for Explainer @@ -57,7 +58,8 @@ def self.get_exported_data(query, team) end def self.update_paragraphs_in_alegre(id, previous_paragraphs_count, timestamp) - explainer = Explainer.find(id) + explainer = Explainer.find_by_id(id) + return if explainer.nil? # Skip if the explainer was saved since this job was created (it means that there is a more recent job) return if explainer.updated_at.to_f > timestamp @@ -131,4 +133,10 @@ def language_in_allowed_values allowed_languages << 'und' errors.add(:language, I18n.t(:"errors.messages.invalid_article_language_value")) unless allowed_languages.include?(self.language) end + + def detach_explainer_if_trashed + if self.trashed && !self.trashed_before_last_save + self.project_medias = [] + end + end end diff --git a/app/models/explainer_item.rb b/app/models/explainer_item.rb index 7fc5be1f49..0fba5d12d9 100644 --- a/app/models/explainer_item.rb +++ b/app/models/explainer_item.rb @@ -7,6 +7,7 @@ class ExplainerItem < ApplicationRecord validates_presence_of :explainer, :project_media validate :same_team + validate :cant_apply_article_to_item_if_article_is_in_the_trash def version_metadata(_changes) { explainer_title: self.explainer.title }.to_json @@ -17,4 +18,8 @@ def version_metadata(_changes) def same_team errors.add(:base, I18n.t(:explainer_and_item_must_be_from_the_same_team)) unless self.explainer&.team_id == self.project_media&.team_id end + + def cant_apply_article_to_item_if_article_is_in_the_trash + errors.add(:base, I18n.t(:cant_apply_article_to_item_if_article_is_in_the_trash)) if self.explainer&.trashed + end end diff --git a/app/models/fact_check.rb b/app/models/fact_check.rb index 5830494615..777c4787cd 100644 --- a/app/models/fact_check.rb +++ b/app/models/fact_check.rb @@ -20,6 +20,7 @@ class FactCheck < ApplicationRecord after_save :update_report, unless: proc { |fc| fc.skip_report_update || !DynamicAnnotation::AnnotationType.where(annotation_type: 'report_design').exists? || fc.project_media.blank? } after_save :update_item_status, if: proc { |fc| fc.saved_change_to_rating? } + after_update :detach_claim_if_trashed def text_fields ['fact_check_title', 'fact_check_summary'] @@ -143,4 +144,12 @@ def set_initial_rating default_rating = self.claim_description.team.verification_statuses('media', nil)['default'] self.rating = pm_rating || default_rating end + + def detach_claim_if_trashed + if self.trashed && !self.trashed_before_last_save + cd = self.claim_description + cd.project_media = nil + cd.save! + end + end end diff --git a/app/models/team.rb b/app/models/team.rb index be43ee2c7b..c1f33d07f3 100644 --- a/app/models/team.rb +++ b/app/models/team.rb @@ -494,6 +494,9 @@ def filtered_explainers(filters = {}) # Filter by date query = query.where(updated_at: Range.new(*format_times_search_range_filter(JSON.parse(filters[:updated_at]), nil))) unless filters[:updated_at].blank? + # Filter by trashed + query = query.where(trashed: !!filters[:trashed]) + # Filter by text query = self.filter_by_keywords(query, filters, 'Explainer') if filters[:text].to_s.size > 2 @@ -534,6 +537,9 @@ def filtered_fact_checks(filters = {}) # Filter by report status query = query.where('fact_checks.report_status' => [filters[:report_status]].flatten.map(&:to_s)) unless filters[:report_status].blank? + # Filter by trashed + query = query.where('fact_checks.trashed' => !!filters[:trashed]) + # Filter by text query = self.filter_by_keywords(query, filters) if filters[:text].to_s.size > 2 diff --git a/config/locales/en.yml b/config/locales/en.yml index 20598d446e..5994644484 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -830,6 +830,7 @@ en: send_on_must_be_in_the_future: can't be in the past. cant_delete_default_folder: The default folder can't be deleted explainer_and_item_must_be_from_the_same_team: Explainer and item must be from the same workspace. + cant_apply_article_to_item_if_article_is_in_the_trash: This article is in the trash so it can't be added to this media cluster. Please first restore it from the trash. shared_feed_imported_media_already_exist: |- No media eligible to be imported into your workspace. The media selected to import already exist in your workspace in the following items: diff --git a/db/migrate/20240913210101_add_trashed_to_articles.rb b/db/migrate/20240913210101_add_trashed_to_articles.rb new file mode 100644 index 0000000000..e3f58fa8ac --- /dev/null +++ b/db/migrate/20240913210101_add_trashed_to_articles.rb @@ -0,0 +1,6 @@ +class AddTrashedToArticles < ActiveRecord::Migration[6.1] + def change + add_column :fact_checks, :trashed, :boolean, default: false, index: true + add_column :explainers, :trashed, :boolean, default: false, index: true + end +end diff --git a/db/schema.rb b/db/schema.rb index 6cd154b4ef..b92f4f4639 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 2024_08_13_155311) do +ActiveRecord::Schema.define(version: 2024_09_13_210101) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -324,6 +324,7 @@ t.datetime "created_at", precision: 6, null: false t.datetime "updated_at", precision: 6, null: false t.string "tags", default: [], array: true + t.boolean "trashed", default: false t.index ["tags"], name: "index_explainers_on_tags", using: :gin t.index ["team_id"], name: "index_explainers_on_team_id" t.index ["user_id"], name: "index_explainers_on_user_id" @@ -344,6 +345,7 @@ t.integer "report_status", default: 0 t.string "rating" t.boolean "imported", default: false + t.boolean "trashed", default: false t.index ["claim_description_id"], name: "index_fact_checks_on_claim_description_id", unique: true t.index ["imported"], name: "index_fact_checks_on_imported" t.index ["language"], name: "index_fact_checks_on_language" diff --git a/lib/relay.idl b/lib/relay.idl index 90c0493bcf..f327c115ea 100644 --- a/lib/relay.idl +++ b/lib/relay.idl @@ -8265,6 +8265,7 @@ type Explainer implements Node { team: PublicTeam team_id: Int title: String + trashed: Boolean updated_at: String url: String user: User @@ -8418,6 +8419,7 @@ type FactCheck implements Node { summary: String tags: [String] title: String + trashed: Boolean updated_at: String url: String user: User @@ -13167,10 +13169,11 @@ type Team implements Node { tags: [String] target_id: Int text: String + trashed: Boolean = false updated_at: String user_ids: [Int] ): ArticleUnionConnection - articles_count(article_type: String, imported: Boolean, language: [String], publisher_ids: [Int], rating: [String], report_status: [String], standalone: Boolean, tags: [String], target_id: Int, text: String, updated_at: String, user_ids: [Int]): Int + articles_count(article_type: String, imported: Boolean, language: [String], publisher_ids: [Int], rating: [String], report_status: [String], standalone: Boolean, tags: [String], target_id: Int, text: String, trashed: Boolean = false, updated_at: String, user_ids: [Int]): Int available_newsletter_header_types: JsonStringType avatar: String check_search_spam: CheckSearch @@ -15590,6 +15593,7 @@ input UpdateExplainerInput { language: String tags: [String] title: String + trashed: Boolean url: String } @@ -15620,6 +15624,7 @@ input UpdateFactCheckInput { summary: String tags: [String] title: String + trashed: Boolean url: String } diff --git a/lib/sample_data.rb b/lib/sample_data.rb index 37c5eca8f3..a4e3499ac7 100644 --- a/lib/sample_data.rb +++ b/lib/sample_data.rb @@ -899,7 +899,7 @@ def create_claim_description(options = {}) description: random_string, context: random_string, user: options[:user] || create_user, - project_media: options[:project_media] || create_project_media + project_media: options.has_key?(:project_media) ? options[:project_media] : create_project_media }.merge(options)) end diff --git a/public/relay.json b/public/relay.json index f62898b0fa..89102460d5 100644 --- a/public/relay.json +++ b/public/relay.json @@ -44795,6 +44795,20 @@ "isDeprecated": false, "deprecationReason": null }, + { + "name": "trashed", + "description": null, + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, { "name": "updated_at", "description": null, @@ -45621,6 +45635,20 @@ "isDeprecated": false, "deprecationReason": null }, + { + "name": "trashed", + "description": null, + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, { "name": "updated_at", "description": null, @@ -69084,6 +69112,18 @@ "defaultValue": null, "isDeprecated": false, "deprecationReason": null + }, + { + "name": "trashed", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "defaultValue": "false", + "isDeprecated": false, + "deprecationReason": null } ], "type": { @@ -69265,6 +69305,18 @@ "defaultValue": null, "isDeprecated": false, "deprecationReason": null + }, + { + "name": "trashed", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "defaultValue": "false", + "isDeprecated": false, + "deprecationReason": null } ], "type": { @@ -85404,6 +85456,18 @@ "isDeprecated": false, "deprecationReason": null }, + { + "name": "trashed", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, { "name": "clientMutationId", "description": "A unique identifier for the client performing the mutation.", @@ -85584,6 +85648,18 @@ "isDeprecated": false, "deprecationReason": null }, + { + "name": "trashed", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, { "name": "clientMutationId", "description": "A unique identifier for the client performing the mutation.", diff --git a/test/models/claim_description_test.rb b/test/models/claim_description_test.rb index 2ff78762df..3a8418606d 100644 --- a/test/models/claim_description_test.rb +++ b/test/models/claim_description_test.rb @@ -197,4 +197,16 @@ def setup cd.save! assert_equal pm, cd.project_media_was end + + test "should not attach to item if fact-check is in the trash" do + t = create_team + cd = create_claim_description team: t, project_media: nil + fc = create_fact_check claim_description: cd, trashed: true + pm = create_project_media team: t + assert_raises ActiveRecord::RecordInvalid do + cd = ClaimDescription.find(cd.id) + cd.project_media = pm + cd.save! + end + end end diff --git a/test/models/explainer_item_test.rb b/test/models/explainer_item_test.rb index 39fff5a3c9..1565fd88c5 100644 --- a/test/models/explainer_item_test.rb +++ b/test/models/explainer_item_test.rb @@ -109,4 +109,13 @@ def teardown end end end + + test "should not attach to item if explainer is in the trash" do + t = create_team + pm = create_project_media team: t + ex = create_explainer team: t, trashed: true + assert_raises ActiveRecord::RecordInvalid do + ex.project_medias << pm + end + end end diff --git a/test/models/explainer_test.rb b/test/models/explainer_test.rb index a902379b6c..a74124c711 100644 --- a/test/models/explainer_test.rb +++ b/test/models/explainer_test.rb @@ -120,4 +120,36 @@ def setup pm.destroy! end end + + test "should detach from items when explainer is sent to the trash" do + Sidekiq::Testing.fake! + t = create_team + ex = create_explainer team: t + pm = create_project_media team: t + pm.explainers << ex + assert_equal [ex], pm.reload.explainers + assert_equal [pm], ex.reload.project_medias + assert_difference 'ExplainerItem.count', -1 do + ex = Explainer.find(ex.id) + ex.trashed = true + ex.save! + end + assert_equal [], pm.reload.explainers + assert_equal [], ex.reload.project_medias + end + + test "should delete after days in the trash" do + t = create_team + pm = create_project_media team: t + ex = create_explainer team: t + Sidekiq::Testing.inline! do + assert_no_difference 'ProjectMedia.count' do + assert_difference 'Explainer.count', -1 do + ex = Explainer.find(ex.id) + ex.trashed = true + ex.save! + end + end + end + end end diff --git a/test/models/fact_check_test.rb b/test/models/fact_check_test.rb index 670cd0e599..54baa29f6d 100644 --- a/test/models/fact_check_test.rb +++ b/test/models/fact_check_test.rb @@ -181,7 +181,7 @@ def setup assert_nil pm.reload.published_url d = create_dynamic_annotation annotation_type: 'report_design', annotator: u, annotated: pm, set_fields: { options: { language: 'en', use_text_message: true, title: 'Text report created title', text: 'Text report created summary', published_article_url: 'http://text.report/created' } }.to_json, action: 'save' - fc = cd.fact_check + fc = cd.reload.fact_check assert_equal 'Text report created title', pm.reload.fact_check_title assert_equal 'Text report created summary', pm.reload.fact_check_summary assert_equal 'http://text.report/created', pm.reload.published_url @@ -423,7 +423,7 @@ def setup s.status = 'verified' s.save! r = publish_report(pm) - fc = cd.fact_check + fc = cd.reload.fact_check fc.title = 'Foo Bar' fc.save! fc = fc.reload @@ -548,4 +548,52 @@ def setup fc = create_fact_check assert_not_nil fc.team end + + test "should unpublish report when fact-check is sent to the trash" do + Sidekiq::Testing.fake! + RequestStore.store[:skip_cached_field_update] = false + pm = create_project_media + cd = create_claim_description(project_media: pm) + fc = create_fact_check claim_description: cd + r = publish_report(pm) + assert_equal pm, cd.reload.project_media + assert_equal 'published', pm.reload.report_status + assert_equal 'published', fc.reload.report_status + assert_equal 'published', r.reload.data['state'] + + fc = FactCheck.find(fc.id) + fc.trashed = true + fc.save! + + assert_nil cd.reload.project_media + assert_equal 'paused', pm.reload.report_status + assert_equal 'paused', fc.reload.report_status + assert_equal 'paused', r.reload.data['state'] + + fc = FactCheck.find(fc.id) + fc.trashed = false + fc.save! + + assert_nil cd.reload.project_media + assert_equal 'paused', pm.reload.report_status + assert_equal 'paused', fc.reload.report_status + assert_equal 'paused', r.reload.data['state'] + end + + test "should delete after days in the trash" do + pm = create_project_media + cd = create_claim_description(project_media: pm) + fc = create_fact_check claim_description: cd + Sidekiq::Testing.inline! do + assert_no_difference 'ProjectMedia.count' do + assert_difference 'FactCheck.count', -1 do + assert_difference 'ClaimDescription.count', -1 do + fc = FactCheck.find(fc.id) + fc.trashed = true + fc.save! + end + end + end + end + end end diff --git a/test/models/team_test.rb b/test/models/team_test.rb index 2b0f0c176d..12023ccad7 100644 --- a/test/models/team_test.rb +++ b/test/models/team_test.rb @@ -1076,7 +1076,7 @@ def setup assert_equal 'Custom Status 2 Changed', r.reload.data.dig('options', 'status_label') end - test "should add trashed link to duplicated team" do + test "should add trash link to duplicated team" do m = create_valid_media t1 = create_team t2 = Team.duplicate(t1) @@ -1251,4 +1251,21 @@ def setup t = create_team assert_equal [], t.fact_checks.to_a end + + test "should return trashed and non-trashed articles" do + Sidekiq::Testing.fake! + t = create_team + create_explainer team: t, trashed: true + create_explainer team: t, trashed: false + create_explainer team: t, trashed: false + create_fact_check claim_description: create_claim_description(project_media: create_project_media(team: t)), trashed: true + create_fact_check claim_description: create_claim_description(project_media: create_project_media(team: t)), trashed: false + create_fact_check claim_description: create_claim_description(project_media: create_project_media(team: t)), trashed: false + assert_equal 2, t.filtered_explainers.count + assert_equal 2, t.filtered_explainers(trashed: false).count + assert_equal 1, t.filtered_explainers(trashed: true).count + assert_equal 2, t.filtered_fact_checks.count + assert_equal 2, t.filtered_fact_checks(trashed: false).count + assert_equal 1, t.filtered_fact_checks(trashed: true).count + end end