diff --git a/app/models/budget/investment.rb b/app/models/budget/investment.rb index bd3e5b466..173a7fd36 100644 --- a/app/models/budget/investment.rb +++ b/app/models/budget/investment.rb @@ -17,6 +17,7 @@ class Budget acts_as_votable acts_as_paranoid column: :hidden_at include ActsAsParanoidAliases + include Relationable belongs_to :author, -> { with_hidden }, class_name: 'User', foreign_key: 'author_id' belongs_to :heading diff --git a/app/models/concerns/relationable.rb b/app/models/concerns/relationable.rb new file mode 100644 index 000000000..26f755f03 --- /dev/null +++ b/app/models/concerns/relationable.rb @@ -0,0 +1,23 @@ +module Relationable + extend ActiveSupport::Concern + + included do + has_many :related_contents, as: :parent_relationable, dependent: :destroy + end + + def relate_content(relationable) + RelatedContent.find_or_create_by(parent_relationable: self, child_relationable: relationable) + end + + def relationed_contents + related_contents.not_hidden.map { |related_content| related_content.child_relationable } + end + + def report_related_content(relationable) + related_content = related_contents.find_by(child_relationable: relationable) + if related_content.present? + related_content.increment!(:times_reported) + related_content.opposite_related_content.increment!(:times_reported) + end + end +end diff --git a/app/models/debate.rb b/app/models/debate.rb index 65c06345d..e949e8092 100644 --- a/app/models/debate.rb +++ b/app/models/debate.rb @@ -9,6 +9,7 @@ class Debate < ActiveRecord::Base include Filterable include HasPublicAuthor include Graphqlable + include Relationable acts_as_votable acts_as_paranoid column: :hidden_at diff --git a/app/models/proposal.rb b/app/models/proposal.rb index 3b0696d26..aaf5bea87 100644 --- a/app/models/proposal.rb +++ b/app/models/proposal.rb @@ -17,6 +17,7 @@ class Proposal < ActiveRecord::Base max_file_size: 3.megabytes, accepted_content_types: [ "application/pdf" ] include EmbedVideosHelper + include Relationable acts_as_votable acts_as_paranoid column: :hidden_at diff --git a/app/models/related_content.rb b/app/models/related_content.rb new file mode 100644 index 000000000..ab72aefe3 --- /dev/null +++ b/app/models/related_content.rb @@ -0,0 +1,33 @@ +class RelatedContent < ActiveRecord::Base + RELATED_CONTENTS_REPORT_THRESHOLD = Setting['related_contents_report_threshold'].to_i + + belongs_to :parent_relationable, polymorphic: true + belongs_to :child_relationable, polymorphic: true + has_one :opposite_related_content, class_name: 'RelatedContent', foreign_key: :related_content_id + + validates :parent_relationable_id, presence: true + validates :parent_relationable_type, presence: true + validates :child_relationable_id, presence: true + validates :child_relationable_type, presence: true + validates :parent_relationable_id, uniqueness: { scope: [:parent_relationable_type, :child_relationable_id, :child_relationable_type] } + + after_create :create_opposite_related_content, unless: proc { opposite_related_content.present? } + after_destroy :destroy_opposite_related_content, if: proc { opposite_related_content.present? } + + scope :not_hidden, -> { where('times_reported <= ?', RELATED_CONTENTS_REPORT_THRESHOLD) } + + def hidden_by_reports? + times_reported > RELATED_CONTENTS_REPORT_THRESHOLD + end + + private + + def create_opposite_related_content + related_content = RelatedContent.create!(opposite_related_content: self, parent_relationable: child_relationable, child_relationable: parent_relationable) + self.opposite_related_content = related_content + end + + def destroy_opposite_related_content + opposite_related_content.destroy + end +end diff --git a/db/dev_seeds.rb b/db/dev_seeds.rb index a6e471d98..8476f7693 100644 --- a/db/dev_seeds.rb +++ b/db/dev_seeds.rb @@ -65,6 +65,7 @@ section "Creating Settings" do Setting.create(key: 'map_latitude', value: 51.48) Setting.create(key: 'map_longitude', value: 0.0) Setting.create(key: 'map_zoom', value: 10) + Setting.create(key: 'related_contents_report_threshold', value: 2) end section "Creating Geozones" do diff --git a/db/migrate/20171127171925_create_related_content.rb b/db/migrate/20171127171925_create_related_content.rb new file mode 100644 index 000000000..2944938e2 --- /dev/null +++ b/db/migrate/20171127171925_create_related_content.rb @@ -0,0 +1,12 @@ +class CreateRelatedContent < ActiveRecord::Migration + def change + create_table :related_contents do |t| + t.references :parent_relationable, polymorphic: true, index: { name: 'index_related_contents_on_parent_relationable' } + t.references :child_relationable, polymorphic: true, index: { name: 'index_related_contents_on_child_relationable' } + t.references :related_content, index: { name: 'opposite_related_content' } + t.timestamps + end + + add_index :related_contents, [:parent_relationable_id, :parent_relationable_type, :child_relationable_id, :child_relationable_type], name: "unique_parent_child_related_content", unique: true, using: :btree + end +end diff --git a/db/migrate/20171127230716_add_time_reported_to_related_content.rb b/db/migrate/20171127230716_add_time_reported_to_related_content.rb new file mode 100644 index 000000000..5cca12cf3 --- /dev/null +++ b/db/migrate/20171127230716_add_time_reported_to_related_content.rb @@ -0,0 +1,5 @@ +class AddTimeReportedToRelatedContent < ActiveRecord::Migration + def change + add_column :related_contents, :times_reported, :integer, default: 0 + end +end diff --git a/db/schema.rb b/db/schema.rb index f2dd62428..4d426cf9e 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -11,7 +11,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 20171115164152) do +ActiveRecord::Schema.define(version: 20171127230716) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -851,6 +851,22 @@ ActiveRecord::Schema.define(version: 20171115164152) do add_index "proposals", ["title"], name: "index_proposals_on_title", using: :btree add_index "proposals", ["tsv"], name: "index_proposals_on_tsv", using: :gin + create_table "related_contents", force: :cascade do |t| + t.integer "parent_relationable_id" + t.string "parent_relationable_type" + t.integer "child_relationable_id" + t.string "child_relationable_type" + t.integer "related_content_id" + t.datetime "created_at" + t.datetime "updated_at" + t.integer "times_reported", default: 0 + end + + add_index "related_contents", ["child_relationable_type", "child_relationable_id"], name: "index_related_contents_on_child_relationable", using: :btree + add_index "related_contents", ["parent_relationable_id", "parent_relationable_type", "child_relationable_id", "child_relationable_type"], name: "unique_parent_child_related_content", unique: true, using: :btree + add_index "related_contents", ["parent_relationable_type", "parent_relationable_id"], name: "index_related_contents_on_parent_relationable", using: :btree + add_index "related_contents", ["related_content_id"], name: "opposite_related_content", using: :btree + create_table "settings", force: :cascade do |t| t.string "key" t.string "value" diff --git a/db/seeds.rb b/db/seeds.rb index 0e49983a5..6ef55067c 100644 --- a/db/seeds.rb +++ b/db/seeds.rb @@ -116,3 +116,6 @@ Setting['proposal_improvement_path'] = nil Setting['map_latitude'] = 51.48 Setting['map_longitude'] = 0.0 Setting['map_zoom'] = 10 + +# Related content +Setting['related_contents_report_threshold'] = 5 diff --git a/spec/factories.rb b/spec/factories.rb index b98fc7adb..1ac2f3ebd 100644 --- a/spec/factories.rb +++ b/spec/factories.rb @@ -882,4 +882,7 @@ LOREM_IPSUM end end + factory :related_content do + end + end diff --git a/spec/models/relation_spec.rb b/spec/models/relation_spec.rb new file mode 100644 index 000000000..86bbe2c14 --- /dev/null +++ b/spec/models/relation_spec.rb @@ -0,0 +1,76 @@ +require 'rails_helper' + +describe RelatedContent do + + let(:parent_relationable) { create([:proposal, :debate, :budget_investment].sample) } + let(:child_relationable) { create([:proposal, :debate, :budget_investment].sample) } + + it "should allow relationables from various classes" do + expect(build(:related_content, parent_relationable: parent_relationable, child_relationable: child_relationable)).to be_valid + expect(build(:related_content, parent_relationable: parent_relationable, child_relationable: child_relationable)).to be_valid + expect(build(:related_content, parent_relationable: parent_relationable, child_relationable: child_relationable)).to be_valid + end + + it "should not allow empty relationables" do + expect(build(:related_content)).not_to be_valid + expect(build(:related_content, parent_relationable: parent_relationable)).not_to be_valid + expect(build(:related_content, child_relationable: child_relationable)).not_to be_valid + end + + it "should not allow repeated related contents" do + related_content = create(:related_content, parent_relationable: parent_relationable, child_relationable: child_relationable) + new_related_content = build(:related_content, parent_relationable: related_content.parent_relationable, child_relationable: related_content.child_relationable) + expect(new_related_content).not_to be_valid + end + + describe 'create_opposite_related_content' do + let(:parent_relationable) { create(:proposal) } + let(:child_relationable) { create(:debate) } + let(:related_content) { build(:related_content, parent_relationable: parent_relationable, child_relationable: child_relationable) } + + it 'creates an opposite related_content' do + expect { related_content.save }.to change { RelatedContent.count }.by(2) + expect(related_content.opposite_related_content.child_relationable_id).to eq(parent_relationable.id) + expect(related_content.opposite_related_content.child_relationable_type).to eq(parent_relationable.class.name) + expect(related_content.opposite_related_content.parent_relationable_id).to eq(child_relationable.id) + expect(related_content.opposite_related_content.parent_relationable_type).to eq(child_relationable.class.name) + expect(related_content.opposite_related_content.opposite_related_content.id).to eq(related_content.id) + end + end + + describe 'relationable destroy' do + let(:parent_relationable) { create(:proposal) } + let(:child_relationable) { create(:debate) } + + it 'destroys both related contents involved' do + related_content = create(:related_content, parent_relationable: parent_relationable, child_relationable: child_relationable) + expect { related_content.parent_relationable.destroy }.to change { RelatedContent.all.count }.by(-2) + expect(child_relationable.related_contents).to be_empty + end + end + + # TODO: Move this into a Relationable shared context + describe '#report_related_content' do + it 'increments both relation and opposite relation times_reported counters' do + related_content = create(:related_content, parent_relationable: parent_relationable, child_relationable: child_relationable) + parent_relationable.report_related_content(child_relationable) + + expect(related_content.reload.times_reported).to eq(1) + expect(related_content.reload.opposite_related_content.times_reported).to eq(1) + end + end + + describe '#relationed_contents' do + before do + create(:related_content, parent_relationable: parent_relationable, child_relationable: create(:proposal), times_reported: 6) + create(:related_content, parent_relationable: parent_relationable, child_relationable: child_relationable) + end + + it 'returns not hidden by reports related contents' do + expect(parent_relationable.relationed_contents.count).to eq(1) + expect(parent_relationable.relationed_contents.first.class.name).to eq(child_relationable.class.name) + expect(parent_relationable.relationed_contents.first.id).to eq(child_relationable.id) + end + end + +end