Comments count according to display rules in feed (#20753)

* Move calculating comments score from worker to a service

* Display displayed comments count in feed

* Recalculating displayed comments counts on article show (if needed)

* Rubocop fixes

* Fixed lambda
This commit is contained in:
Anna Buianova 2024-03-28 15:12:05 +03:00 committed by GitHub
parent ec32a8e7cc
commit 51316c747f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 144 additions and 40 deletions

View file

@ -286,7 +286,7 @@ class StoriesController < ApplicationController
@organization = @article.organization @organization = @article.organization
@comments_order = fetch_sort_order @comments_order = fetch_sort_order
@comments_count = Comments::Count.new(@article).call @comments_count = Comments::Count.call(@article)
if @article.collection if @article.collection
@collection = @article.collection @collection = @article.collection

View file

@ -4,22 +4,33 @@
module Comments module Comments
class Count class Count
attr_reader :article def self.call(...)
new(...).call
end
def initialize(article) def initialize(article, recalculate: false)
@article = article @article = article
@recalculate = recalculate
end end
def call def call
# comments that are not displayed at all (not even a "comment deleted" message): if recalculate || !article.displayed_comments_count?
# with the score below hiding threshold and w/o children # comments that are not displayed at all (not even a "comment deleted" message):
count_sql = "SELECT COUNT(id) FROM comments c1 WHERE score < ? AND commentable_id = ? " \ # with the score below hiding threshold and w/o children
"AND commentable_type = ? AND NOT EXISTS " \ count_sql = "SELECT COUNT(id) FROM comments c1 WHERE score < ? AND commentable_id = ? " \
"(SELECT 1 FROM comments c2 WHERE c2.ancestry LIKE CONCAT('%/', c1.id::varchar(255)) " \ "AND commentable_type = ? AND NOT EXISTS " \
"OR c2.ancestry = c1.id::varchar(255))" "(SELECT 1 FROM comments c2 WHERE c2.ancestry LIKE CONCAT('%/', c1.id::varchar(255)) " \
san_count_sql = Comment.sanitize_sql([count_sql, Comment::HIDE_THRESHOLD, @article.id, "Article"]) "OR c2.ancestry = c1.id::varchar(255))"
hidden_comments_cnt = Comment.count_by_sql(san_count_sql) san_count_sql = Comment.sanitize_sql([count_sql, Comment::HIDE_THRESHOLD, @article.id, "Article"])
article.comments_count - hidden_comments_cnt hidden_comments_cnt = Comment.count_by_sql(san_count_sql)
displayed_comments_count = article.comments.count - hidden_comments_cnt
article.update_column(:displayed_comments_count, displayed_comments_count)
end
article.displayed_comments_count
end end
private
attr_reader :article, :recalculate
end end
end end

View file

@ -4,6 +4,7 @@ module Homepage
cached_tag_list cached_tag_list
comments_count comments_count
crossposted_at crossposted_at
displayed_comments_count
id id
organization_id organization_id
path path

View file

@ -20,7 +20,6 @@ module Homepage
attributes( attributes(
:class_name, :class_name,
:cloudinary_video_url, :cloudinary_video_url,
:comments_count,
:id, :id,
:path, :path,
:public_reactions_count, :public_reactions_count,
@ -31,6 +30,10 @@ module Homepage
:public_reaction_categories, :public_reaction_categories,
) )
# return displayed_comments_count (excluding low score comments) if it was calculated earlier
attribute :comments_count, (lambda do |article|
article.displayed_comments_count? ? article.displayed_comments_count : article.comments_count
end)
attribute :video_duration_string, &:video_duration_in_minutes attribute :video_duration_string, &:video_duration_in_minutes
attribute :published_at_int, ->(article) { article.published_at.to_i } attribute :published_at_int, ->(article) { article.published_at.to_i }
attribute :tag_list, ->(article) { article.cached_tag_list.to_s.split(", ") } attribute :tag_list, ->(article) { article.cached_tag_list.to_s.split(", ") }

View file

@ -0,0 +1,32 @@
module Comments
class CalculateScore
def self.call(...)
new(...).call
end
def initialize(comment)
@comment = comment
end
def call
score = BlackBox.comment_quality_score(comment)
score -= 500 if comment.user&.spam?
comment.update_columns(score: score, updated_at: Time.current)
comment.user&.touch(:last_comment_at)
# update commentable
commentable = comment.commentable
commentable.touch(:last_comment_at) if commentable.respond_to?(:last_comment_at)
Comments::Count.call(commentable, recalculate: true) if commentable.is_a?(Article)
# busting comment cache includes busting commentable cache
Comments::BustCacheWorker.new.perform(comment.id)
end
private
attr_reader :comment
end
end

View file

@ -8,15 +8,7 @@ module Comments
comment = Comment.find_by(id: comment_id) comment = Comment.find_by(id: comment_id)
return unless comment return unless comment
score = BlackBox.comment_quality_score(comment) Comments::CalculateScore.call(comment)
score -= 500 if comment.user&.spam?
comment.update_columns(score: score, updated_at: Time.current)
comment.commentable.touch(:last_comment_at) if comment.commentable.respond_to?(:last_comment_at)
comment.user.touch(:last_comment_at) if comment.user
# busting comment cache includes busting commentable cache
Comments::BustCacheWorker.new.perform(comment.id)
end end
end end
end end

View file

@ -0,0 +1,5 @@
class AddDisplayedCommentsCountToArticles < ActiveRecord::Migration[7.0]
def change
add_column :articles, :displayed_comments_count, :integer
end
end

View file

@ -103,6 +103,7 @@ ActiveRecord::Schema[7.0].define(version: 2024_03_06_173309) do
t.datetime "created_at", precision: nil, null: false t.datetime "created_at", precision: nil, null: false
t.datetime "crossposted_at", precision: nil t.datetime "crossposted_at", precision: nil
t.string "description" t.string "description"
t.integer "displayed_comments_count"
t.datetime "edited_at", precision: nil t.datetime "edited_at", precision: nil
t.boolean "email_digest_eligible", default: true t.boolean "email_digest_eligible", default: true
t.float "experience_level_rating", default: 5.0 t.float "experience_level_rating", default: 5.0

View file

@ -51,4 +51,37 @@ RSpec.describe Comments::Count do
count = described_class.new(article).call count = described_class.new(article).call
expect(count).to eq(4) expect(count).to eq(4)
end end
context "with recalculate option" do
# displayed comments count = 4
# comments count = 5
before do
comment.update_column(:score, -500)
create(:comment, commentable: article, parent: comment, score: 10)
create(:comment, commentable: article, parent: comment, score: -490)
create(:comment, commentable: article, parent: comment2, score: 0)
article.reload
end
it "returns displayed_comments_count if it exists + no recalculate" do
article.update_column(:displayed_comments_count, 3)
cnt = described_class.call(article, recalculate: false)
expect(cnt).to eq(3)
end
it "recalculates if recalculate is passed", :aggregate_failures do
article.update_column(:displayed_comments_count, 3)
cnt = described_class.call(article, recalculate: true)
expect(cnt).to eq(4)
article.reload
expect(article.displayed_comments_count).to eq(4)
end
it "recalculates if no recalculate and no displayed_comments_count", :aggregate_failures do
cnt = described_class.call(article, recalculate: false)
expect(cnt).to eq(4)
article.reload
expect(article.displayed_comments_count).to eq(4)
end
end
end end

View file

@ -0,0 +1,40 @@
require "rails_helper"
RSpec.describe Comments::CalculateScore, type: :service do
let(:article) { create(:article) }
let(:comment) { create(:comment, commentable: article) }
let(:user) { instance_double(User, spam?: false) }
before do
allow(BlackBox).to receive(:comment_quality_score).and_return(7)
end
it "updates the score" do
described_class.call(comment)
comment.reload
expect(comment.score).to be(7)
end
context "when adding spam role" do
before do
comment.user.add_role(:spam)
comment.update_column(:updated_at, 1.day.ago)
end
it "updates the score and updated_at with a penalty if the user is a spammer", :aggregate_failures do
described_class.call(comment)
comment.reload
expect(comment.score).to be(-493)
expect(comment.updated_at).to be_within(1.minute).of(Time.current)
end
it "updates article displayed comments count" do
other_comment = create(:comment, commentable: article)
create(:comment, commentable: article, parent: other_comment)
described_class.call(comment)
article.reload
expect(article.comments_count).to eq(3)
expect(article.displayed_comments_count).to eq(2)
end
end
end

View file

@ -7,29 +7,15 @@ RSpec.describe Comments::CalculateScoreWorker, type: :worker do
let(:worker) { subject } let(:worker) { subject }
context "with comment" do context "with comment" do
let(:article) { create(:article) } let(:comment) { create(:comment) }
let(:comment) { create(:comment, commentable: article) }
let(:root_comment) { instance_double(Comment) }
let(:user) { instance_double(User, spam?: false) }
before do before do
allow(BlackBox).to receive(:comment_quality_score).and_return(7) allow(Comments::CalculateScore).to receive(:call)
end end
it "updates the score" do it "calls CalculateScore" do
worker.perform(comment.id) worker.perform(comment.id)
expect(Comments::CalculateScore).to have_received(:call).with(comment)
comment.reload
expect(comment.score).to be(7)
end
it "updates the score and updated_at with a penalty if the user is a spammer", :aggregate_failures do
comment.user.add_role(:spam)
comment.update_column(:updated_at, 1.day.ago)
worker.perform(comment.id)
comment.reload
expect(comment.score).to be(-493)
expect(comment.updated_at).to be_within(1.minute).of(Time.current)
end end
end end