From cd170ca5483299f029fd5587a5f36cda2018fa67 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9gis=20Hanol?= Date: Fri, 5 Dec 2014 19:37:43 +0100 Subject: [PATCH] FEATURE: auto-close topics based on community flags --- app/models/post_action.rb | 79 ++++++++++++++++++++++++++++++- app/models/topic.rb | 4 +- app/models/topic_status_update.rb | 8 ++-- config/locales/server.en.yml | 5 ++ config/site_settings.yml | 2 + lib/maximum_flow.rb | 69 +++++++++++++++++++++++++++ spec/models/post_action_spec.rb | 49 +++++++++++++++++++ 7 files changed, 208 insertions(+), 8 deletions(-) create mode 100644 lib/maximum_flow.rb diff --git a/app/models/post_action.rb b/app/models/post_action.rb index b224f452767..e7dba7086c2 100644 --- a/app/models/post_action.rb +++ b/app/models/post_action.rb @@ -1,5 +1,6 @@ require_dependency 'rate_limiter' require_dependency 'system_message' +require_dependency 'maximum_flow' class PostAction < ActiveRecord::Base class AlreadyActed < StandardError; end @@ -232,7 +233,7 @@ class PostAction < ActiveRecord::Base else post_action = PostAction.where(where_attrs).first - # after_commit is not called on an `update_all` so do the notify ourselves + # after_commit is not called on an 'update_all' so do the notify ourselves post_action.notify_subscribers end @@ -349,7 +350,7 @@ class PostAction < ActiveRecord::Base # Voting also changes the sort_order Post.where(id: post_id).update_all ["vote_count = :count, sort_order = :max - :count", count: count, max: Topic.max_sort_order] when :like - # `like_score` is weighted higher for staff accounts + # 'like_score' is weighted higher for staff accounts score = PostAction.joins(:user) .where(post_id: post_id) .sum("CASE WHEN users.moderator OR users.admin THEN #{SiteSetting.staff_like_weight} ELSE 1 END") @@ -370,6 +371,7 @@ class PostAction < ActiveRecord::Base def enforce_rules post = Post.with_deleted.where(id: post_id).first + PostAction.auto_close_if_treshold_reached(post.topic) PostAction.auto_hide_if_needed(user, post, post_action_type_key) SpamRulesEnforcer.enforce!(post.user) if post_action_type_key == :spam end @@ -380,6 +382,79 @@ class PostAction < ActiveRecord::Base end end + MAXIMUM_FLAGS_PER_POST = 3 + + def self.auto_close_if_treshold_reached(topic) + return if topic.closed? + + # 1) retrieve a list of pairs (user_id, post_id) representing active flags + flags = PostAction.active + .flags + .joins(:post) + .where("posts.topic_id = ?", topic.id) + .where.not(user_id: Discourse::SYSTEM_USER_ID) + .pluck(:user_id, :post_id) + + # check we have enough flags + return if flags.count < SiteSetting.num_flags_to_close_topic + + # 2) build sets of unique user_ids and post_ids + user_ids = Set.new + post_ids = Set.new + + flags.each do |f| + user_ids << f[0] + post_ids << f[1] + end + + # check we have enough flaggers + return if user_ids.count < SiteSetting.num_flaggers_to_close_topic + # check we have enough posts flagged + min_post_required = SiteSetting.num_flags_to_close_topic / MAXIMUM_FLAGS_PER_POST + return if post_ids.count < min_post_required + + # 3) now we have a maximum flow problem... + # the network will have + # - edges from the 'source' to each flaggers with a capacity of '# of flags casted by that user' + # - edges for each flags with a capacity of 1 + # - edges from each posts to the 'sink' with a capacity of MAXIMUM_FLAGS_PER_POST + + # first, we need to count the # of flags casted by each users + flags_casted_by_user = {} + flags.each { |flag| flags_casted_by_user[flag[0]] = flags.count { |f| f[0] == flag[0] } } + + # then, we need to build a list of all the vertices + # ('source' being the first and 'sink" being the last) + index_of_user_id = {} + index_of_post_id = {} + + user_ids.each_with_index { |user_id, index| index_of_user_id[user_id] = 1 + index } + post_ids.each_with_index { |post_id, index| index_of_post_id[post_id] = 1 + index + user_ids.count } + + source = 0 + sink = user_ids.count + post_ids.count + 1 + n = sink + 1 + + # then, we need to build a map of all the edges (with their respective capacity) + # initially, everything is 0 (ie. no edge) + capacities = Array.new(n) { Array.new(n, 0) } + + # from the 'source' -> all user_ids with a capacity of '# of flags casted by that user' + user_ids.each { |user_id| capacities[source][index_of_user_id[user_id]] = flags_casted_by_user[user_id] } + # for each pair (user_id, post_id) with a capacity of 1 + flags.each { |f| capacities[index_of_user_id[f[0]]][index_of_post_id[f[1]]] = 1 } + # from each post_ids -> sink with a capacity of MAXIMUM_FLAGS_PER_POST + index_of_post_id.values.each { |i| capacities[i][sink] = MAXIMUM_FLAGS_PER_POST } + + # finally, we use the 'relabel to front' algorithm to solve the maximum flow problem + maximum_flow = MaximumFlow.new.relabel_to_front(capacities, source, sink) + return if maximum_flow < SiteSetting.num_flags_to_close_topic + + # 4) the threshold has been reached, we will close the topic waiting for intervention + message = I18n.t("temporarily_closed_due_to_flags") + topic.update_status("closed", true, Discourse.system_user, message) + end + def self.auto_hide_if_needed(acting_user, post, post_action_type) return if post.hidden diff --git a/app/models/topic.rb b/app/models/topic.rb index d8572832e08..f71b695ac3c 100644 --- a/app/models/topic.rb +++ b/app/models/topic.rb @@ -397,8 +397,8 @@ class Topic < ActiveRecord::Base similar end - def update_status(status, enabled, user) - TopicStatusUpdate.new(self, user).update!(status, enabled) + def update_status(status, enabled, user, message=nil) + TopicStatusUpdate.new(self, user).update!(status, enabled, message) end # Atomically creates the next post number diff --git a/app/models/topic_status_update.rb b/app/models/topic_status_update.rb index 43ee4055146..4f23d7c3de6 100644 --- a/app/models/topic_status_update.rb +++ b/app/models/topic_status_update.rb @@ -1,12 +1,12 @@ TopicStatusUpdate = Struct.new(:topic, :user) do - def update!(status, enabled) + def update!(status, enabled, message=nil) status = Status.new(status, enabled) Topic.transaction do change(status) highest_post_number = topic.highest_post_number - create_moderator_post_for(status) + create_moderator_post_for(status, message) update_read_state_for(status, highest_post_number) end end @@ -31,8 +31,8 @@ TopicStatusUpdate = Struct.new(:topic, :user) do CategoryFeaturedTopic.feature_topics_for(topic.category) end - def create_moderator_post_for(status) - topic.add_moderator_post(user, message_for(status), options_for(status)) + def create_moderator_post_for(status, message=nil) + topic.add_moderator_post(user, message || message_for(status), options_for(status)) topic.reload end diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml index ab80e0d6b8f..9066964a928 100644 --- a/config/locales/server.en.yml +++ b/config/locales/server.en.yml @@ -945,6 +945,9 @@ en: max_age_unmatched_emails: "Delete unmatched screened email entries after (N) days." max_age_unmatched_ips: "Delete unmatched screened IP entries after (N) days." + num_flaggers_to_close_topic: "Minimum number of unique flaggers that is required to automatically pause a topic for intervention" + num_flags_to_close_topic: "Minimum number of active flags that is required to automatically pause a topic for intervention" + reply_by_email_enabled: "Enable replying to topics via email." reply_by_email_address: "Template for reply by email incoming email address, for example: %{reply_key}@reply.example.com or replies+%{reply_key}@example.com" @@ -1302,6 +1305,8 @@ en: deferred: "Thanks for letting us know. We're looking into it." deferred_and_deleted: "Thanks for letting us know. We've removed the post." + temporarily_closed_due_to_flags: "This topic is temporarily closed due to a large number of community flags" + system_messages: post_hidden: subject_template: "Post hidden due to community flagging" diff --git a/config/site_settings.yml b/config/site_settings.yml index 325ba9b41fa..b7276e60ef1 100644 --- a/config/site_settings.yml +++ b/config/site_settings.yml @@ -587,6 +587,8 @@ spam: min_ban_entries_for_roll_up: 5 max_age_unmatched_emails: 365 max_age_unmatched_ips: 365 + num_flaggers_to_close_topic: 5 + num_flags_to_close_topic: 12 rate_limits: unique_posts_mins: diff --git a/lib/maximum_flow.rb b/lib/maximum_flow.rb new file mode 100644 index 00000000000..0623de94a1b --- /dev/null +++ b/lib/maximum_flow.rb @@ -0,0 +1,69 @@ +# cf. http://en.wikipedia.org/wiki/Maximum_flow_problem +class MaximumFlow + + # cf. http://en.wikipedia.org/wiki/Push%E2%80%93relabel_maximum_flow_algorithm + def relabel_to_front(capacities, source, sink) + n = capacities.length + flow = Array.new(n) { Array.new(n, 0) } + height = Array.new(n, 0) + excess = Array.new(n, 0) + seen = Array.new(n, 0) + queue = (0...n).select { |i| i != source && i != sink }.to_a + + height[source] = n - 1 + excess[source] = Float::INFINITY + (0...n).each { |v| push(source, v, capacities, flow, excess) } + + p = 0 + while p < queue.length + u = queue[p] + h = height[u] + discharge(u, capacities, flow, excess, seen, height, n) + if height[u] > h + queue.unshift(queue.delete_at(p)) + p = 0 + else + p += 1 + end + end + + flow[source].reduce(:+) + end + + private + + def push(u, v, capacities, flow, excess) + residual_capacity = capacities[u][v] - flow[u][v] + send = [excess[u], residual_capacity].min + flow[u][v] += send + flow[v][u] -= send + excess[u] -= send + excess[v] += send + end + + def discharge(u, capacities, flow, excess, seen, height, n) + while excess[u] > 0 + if seen[u] < n + v = seen[u] + if capacities[u][v] - flow[u][v] > 0 && height[u] > height[v] + push(u, v, capacities, flow, excess) + else + seen[u] += 1 + end + else + relabel(u, capacities, flow, height, n) + seen[u] = 0 + end + end + end + + def relabel(u, capacities, flow, height, n) + min_height = Float::INFINITY + (0...n).each do |v| + if capacities[u][v] - flow[u][v] > 0 + min_height = [min_height, height[v]].min + height[u] = min_height + 1 + end + end + end +end diff --git a/spec/models/post_action_spec.rb b/spec/models/post_action_spec.rb index f1501538328..13fe082f229 100644 --- a/spec/models/post_action_spec.rb +++ b/spec/models/post_action_spec.rb @@ -370,6 +370,55 @@ describe PostAction do post.hidden.should == false end + it "will automatically close a topic due to large community flagging" do + SiteSetting.stubs(:flags_required_to_hide_post).returns(0) + SiteSetting.stubs(:num_flags_to_close_topic).returns(12) + SiteSetting.stubs(:num_flaggers_to_close_topic).returns(5) + + topic = Fabricate(:topic) + post1 = create_post(topic: topic) + post2 = create_post(topic: topic) + post3 = create_post(topic: topic) + post4 = create_post(topic: topic) + + flagger1 = Fabricate(:user) + flagger2 = Fabricate(:user) + flagger3 = Fabricate(:user) + flagger4 = Fabricate(:user) + flagger5 = Fabricate(:user) + + # reaching `num_flaggers_to_close_topic` isn't enough + [flagger1, flagger2, flagger3, flagger4, flagger5].each do |flagger| + PostAction.act(flagger, post1, PostActionType.types[:inappropriate]) + end + + topic.reload.closed.should == false + + # clean up + PostAction.where(post: post1).delete_all + + # reaching `num_flags_to_close_topic` isn't enough + [flagger1, flagger2, flagger3].each do |flagger| + [post1, post2, post3, post4].each do |post| + PostAction.act(flagger, post, PostActionType.types[:inappropriate]) + end + end + + topic.reload.closed.should == false + + # clean up + PostAction.where(post: [post1, post2, post3, post4]).delete_all + + # reaching both should close the topic + [flagger1, flagger2, flagger3, flagger4, flagger5].each do |flagger| + [post1, post2, post3, post4].each do |post| + PostAction.act(flagger, post, PostActionType.types[:inappropriate]) + end + end + + topic.reload.closed.should == true + end + end it "prevents user to act twice at the same time" do