Skip to content

Commit

Permalink
mod dashboard: list most-downvoted commenters by stddev
Browse files Browse the repository at this point in the history
  • Loading branch information
pushcx committed Jul 10, 2018
1 parent 5291190 commit 44d7126
Show file tree
Hide file tree
Showing 10 changed files with 209 additions and 96 deletions.
12 changes: 8 additions & 4 deletions app/assets/stylesheets/application.css
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ h2 {
font-size: 11pt;
}

div.clear {
.clear {
clear: both;
}

Expand Down Expand Up @@ -1055,12 +1055,10 @@ table.thread td img {
vertical-align: middle;
}

table.data tr.row0 td,
table.data.zebra tr:nth-child(even) td {
background-color: #f8f8f8;
border-bottom: 1px solid #eaeaea;
}
table.data tr.row1 td,
table.data.zebra tr:nth-child(odd) td {
background-color: #f5f5f5;
border-bottom: 1px solid #eaeaea;
Expand All @@ -1069,6 +1067,9 @@ table.data tr.nobottom td {
border-bottom: 0px;
padding-bottom: 0px;
}
table.data.tall td {
vertical-align: top;
}

table.data tr.void td, table.data tr.void td a {
text-decoration: line-through;
Expand Down Expand Up @@ -1224,11 +1225,11 @@ table.data td p:last-child {
div.flash-error,
div.flash-notice,
div.flash-success,
div.downvoteWarning,
div.errorExplanation {
position: relative;
padding: 7px 15px;
margin-bottom: 18px;
color: white;
background-color: #eedc94;
background-repeat: repeat-x;
background-image: linear-gradient(top, #fceec1, #eedc94);
Expand All @@ -1241,6 +1242,9 @@ div.errorExplanation {
border-radius: 4px;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.25);
}
.downvoteWarning {
color: black;
}
div.flash-error a,
div.flash-notice a,
div.flash-success a,
Expand Down
7 changes: 7 additions & 0 deletions app/controllers/mod_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,13 @@ def downvoted
.order("comments.id DESC"))
end

def commenters
dvc = DownvotedCommenters.new(params[:period])
@interval = dvc.interval
@agg = dvc.aggregates
@commenters = dvc.commenters
end

private

def default_periods
Expand Down
84 changes: 84 additions & 0 deletions app/models/downvoted_commenters.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
# Finds the consistent most-heavily-downvoted commenters. Requires downvotes to be spread over
# several comments and stories because anyone can have a bad thread or a bad day.

class DownvotedCommenters
include IntervalHelper

CACHE_TIME = 30.minutes

attr_reader :interval, :period

def initialize(interval)
@interval = interval
length = time_interval(interval)
@period = length[:dur].send(length[:intv].downcase).ago
end

def check_list_for(showing_user)
commenters[showing_user.id]
end

# aggregates for all commenters; not just those receiving downvotes
def aggregates
Rails.cache.fetch("aggregates_#{interval}", expires_in: CACHE_TIME) {
ActiveRecord::Base.connection.exec_query("
select
sum_downvotes,
stddev(sum_downvotes) as stddev,
sum(sum_downvotes) as sum,
avg(sum_downvotes) as avg,
avg(n_comments) as n_comments,
count(*) as n_commenters
from (
select
sum(downvotes) as sum_downvotes,
count(*) as n_comments
from comments join users on comments.user_id = users.id
where (comments.created_at >= '#{period}')
GROUP BY comments.user_id
) sums;
").first.symbolize_keys!
}
end

def stddev_sum_downvotes
aggregates[:stddev]
end

def avg_sum_downvotes
aggregates[:avg]
end

def commenters
Rails.cache.fetch("downvoted_commenters_#{interval}", expires_in: CACHE_TIME) {
rank = 0
User.active.joins(:comments)
.where("comments.downvotes > 0 and comments.created_at >= ?", period)
.group("comments.user_id")
.select("
users.id, users.username,
(sum(downvotes) - #{avg_sum_downvotes})/#{stddev_sum_downvotes} as sigma,
count(distinct comments.id) as n_comments,
count(distinct story_id) as n_stories,
sum(downvotes) as n_downvotes")
.having("n_comments > 2 and n_stories > 1 and n_downvotes >= 10")
.order("sigma desc")
.limit(30)
.each_with_object({}) {|u, hash|
hash[u.id] = {
username: u.username,
rank: rank += 1,
sigma: u.sigma,
n_comments: u.n_comments,
n_stories: u.n_stories,
n_downvotes: u.n_downvotes,
average_downvotes: u.n_downvotes * 1.0 / u.n_comments,
stddev: 0,
percent_downvoted:
# TODO: fix 1 + n caused by u.comments to grab total comment count
u.n_comments * 100.0 / u.comments.where("created_at >= ?", period).count,
}
}
}
end
end
1 change: 1 addition & 0 deletions app/views/mod/_nav.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
<div class="legend right">
Flagged: <% @periods.each do |p| %><%= link_to_different_page(p, mod_flagged_path(period: p)) %> <% end %>
Downvoted: <% @periods.each do |p| %><%= link_to_different_page(p, mod_downvoted_path(period: p)) %> <% end %>
Commenters: <% %w{1m 2m 3m 6m}.each do |p| %><%= link_to_different_page(p, mod_commenters_path(period: p)) %> <% end %>
</div>

<div class="legend">
Expand Down
39 changes: 39 additions & 0 deletions app/views/mod/commenters.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
<%= render partial: 'nav' %>

<% if @commenters.present? %>
<table class='data zebra' style='margin: 0 auto;'>
<tr>
<th>User</th>
<th title="Number of standard deviations above average commenter">+&sigma;</th>
<th class="r" title="Downvotes per comment">D/C</th>
<th class="r" title="total # of downvotes"># DV</th>
<th class="r" title="total # of downvoted comments"># C</th>
<th class="r" title="(percentage of all of users's comments)">% C</th>
</tr>
<% @commenters.each do |id, user| %><tr id="<%= user[:username] %>">
<td>
<%= link_to user[:username], user_path(user[:username]) %>
<small>(<%= link_to 'threads', user_threads_path(user[:username]) %>)</small>
</td>
<td class="r"><%= '%.1f' % user[:sigma] %></td>
<td class="r"><%= '%.1f' % user[:average_downvotes] %></td>
<td class="r"><%= user[:n_downvotes] %></td>
<td class="r"><%= user[:n_comments] %></td>
<td class="r"><%= user[:percent_downvoted].round %>%</td>
</tr><% end %>
<tr>
<td title="Average of commenters, including the most-downvoted commenters above">
Avg of <%= @agg[:n_commenters] %> commenters
</td>
<td class="r" title="0 by definition; () is standard deviation">
(&sigma; = <%= '%.1f' % @agg[:stddev] %>)
</td>
<td class="r"><%= '%.1f' % @agg[:avg] %></td>
<td class="r"><%= (@agg[:avg] * @agg[:n_comments]).round %></td>
<td class="r"><%= '%.1f' % @agg[:n_comments] %></td>
</tr>
</table>

<% else %>
<div class="nominal">🦞</div>
<% end %>
3 changes: 1 addition & 2 deletions app/views/stories/_form.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -91,8 +91,7 @@ See the guidelines below." %>
<a href="#" id="story_guidelines_toggler">
Story submission guidelines
</a>
<div id="story_guidelines" style="<%= show_guidelines?? "" :
"display: none;" %>">
<div id="story_guidelines" style="<%= show_guidelines?? "" : "display: none;" %>">
<ul>

<li><p>
Expand Down
149 changes: 61 additions & 88 deletions app/views/users/show.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -153,14 +153,17 @@
<% end %>

<% if @user && @user.is_moderator? && !@showing_user.is_moderator? %>
<div style="clear: both;"></div>
<br>
<h2>Moderator Information</h2>

<p>
<div class="legend">
Moderation Information
</div>
</p>
<label class="required">Downvoted (1m):</label>
<span class="d">
<% if (stats = DownvotedCommenters.new('1m').check_list_for(@showing_user)) %>
<a href="/mod/commenters/1m<%= @showing_user.username %>">#<%= stats[:rank] %></a> at <%= '%.2f' % stats[:sigma] %> &sigma;
<% else %>
No
<% end %>
</span>
<br>

<label class="required">Self Promoter:</label>
<span class="d">
Expand All @@ -171,97 +174,67 @@
<% end %>
</span>
<br>
<% end %>

<% if @user && @user.is_admin? && !@showing_user.is_moderator? %>
<div style="clear: both;"></div>
<br>

<p>
<div class="legend">
Administrative Information
</div>
</p>
<% if @user.is_admin? %>
<h3>Admin Information</h3>

<label class="required">E-Mail:</label>
<span class="d">
<%= @showing_user.email %>
</span>
<br>
<label class="required">E-Mail:</label>
<span class="d">
<%= @showing_user.email %>
</span>
<br>

<label class="required">Recent Votes:</label>
<div class="d shorten_first_p">
<% @showing_user.votes_for_others.limit(10).each do |v| %>
<p>
<% if v.vote == 1 %>
+1
<% else %>
<%= v.vote %>
<label class="required">Recent Votes:</label>
<table class="data zebra clear tall">
<% @showing_user.votes_for_others.limit(15).each do |v| %><tr>
<td><%= v.vote == 1 ? '+' : v.reason %></td>
<% if v.comment_id %>
(<%= Vote::COMMENT_REASONS[v.reason] %>)
<% else %>
(<%= Vote::STORY_REASONS[v.reason] %>)
<td><a href="/u/<%= v.comment.user.try(:username) %>"><%= v.comment.user.try(:username) %></a></td>
<td>
<%= v.story.title %>
<a href="<%= v.comment.short_id_url %>">comment</a>:<br>
<%= v.comment.comment.split[0..10].join(' ') %>
</td>
<% elsif v.story_id && !v.comment_id %>
<td><a href="/u/<%= v.story.user.try(:username) %>"><%= v.story.user.try(:username) %></a></td>
<td><a href="<%= v.story.short_id_url %>"><%= v.story.title %></a></td>
<% end %>
</p>
</tr><% end %>
</table>

<% if @showing_user.is_banned? || @showing_user.banned_from_inviting? %>
<%= form_tag user_unban_path, :method => :post do %>
<p>
<% if @showing_user.is_banned? %>
<%= submit_tag "Unban" %>
<% end %>
<% if @showing_user.banned_from_inviting? %>
<%= submit_tag "Enable Invites", formaction: user_enable_invite_path %>
<% end %>
</p>
<% end %>
on
<% if v.comment_id %>
<a href="<%= v.comment.short_id_url %>">comment</a>
by
<a href="/u/<%= v.comment.user.try(:username) %>"><%=
v.comment.user.try(:username) %></a>
on
<a href="<%= v.story.short_id_url %>"><%= v.story.title %></a>
<% elsif v.story_id && !v.comment_id %>
<a href="<%= v.story.short_id_url %>"><%= v.story.title %></a>
by
<a href="/u/<%= v.story.user.try(:username) %>"><%=
v.story.user.try(:username) %></a>
<% end %>
</p>
<% end %>
</div>
<br>

<div style="clear: both;"></div>
<br>

<p>
<div class="legend">
Administrative Actions
</div>
</p>

<% if @showing_user.is_banned? || @showing_user.banned_from_inviting? %>
<%= form_tag user_unban_path, :method => :post do %>
<% if !@showing_user.is_banned? || !@showing_user.banned_from_inviting? %>
<p>
<% if @showing_user.is_banned? %>
<%= submit_tag "Unban" %>
<% end %>
<% if @showing_user.banned_from_inviting? %>
<%= submit_tag "Enable Invites", formaction: user_enable_invite_path %>
<% end %>
</p>
<% end %>
<% end %>

<% if !@showing_user.is_banned? || !@showing_user.banned_from_inviting? %>
<p>
Banning or disabling invites for a user will send an e-mail to the user with the reason below,
with your e-mail address as the Reply-To so the user can respond.
</p>
<%= form_tag user_ban_path, :method => :post do %>
<div class="boxline">
<%= label_tag :reason, "Reason:", :class => "required" %>
<%= text_field_tag :reason, "", :size => 40 %>
</div>
<p>
<% if !@showing_user.is_banned? %>
<%= submit_tag "Ban", class: 'deletion' %>
<% end %>
<% if !@showing_user.banned_from_inviting? %>
<%= submit_tag "Disable Invites", formaction: user_disable_invite_path %>
<% end %>
Banning or disabling invites for a user will send an e-mail to the user with the reason below,
with your e-mail address as the Reply-To so the user can respond.
</p>
<%= form_tag user_ban_path, :method => :post do %>
<div class="boxline">
<%= label_tag :reason, "Reason:", :class => "required" %>
<%= text_field_tag :reason, "", :size => 40 %>
</div>
<p>
<% if !@showing_user.is_banned? %>
<%= submit_tag "Ban", class: 'deletion' %>
<% end %>
<% if !@showing_user.banned_from_inviting? %>
<%= submit_tag "Disable Invites", formaction: user_disable_invite_path %>
<% end %>
</p>
<% end %>
<% end %>
<% end %>
<% end %>
Expand Down
2 changes: 1 addition & 1 deletion config/application.rb
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ def domain
end

def name
"Example News"
"Lobsters"
end

# to force everyone to be considered logged-out (without destroying
Expand Down
Loading

0 comments on commit 44d7126

Please sign in to comment.