add moderation logging

- add users.is_moderator and look at that for most things, not
is_admin

- make default user in readme be a moderator

- log moderator actions in story edits, comment
  deletions/undeletions (and later, user disabling).

- remove ability for moderators to edit comments, there's really no
  reason to.
This commit is contained in:
joshua stein 2012-09-02 09:50:07 -05:00
parent 4692cf63ea
commit e6c74e8251
18 changed files with 223 additions and 45 deletions

View file

@ -51,6 +51,7 @@ This is the source code to the site operating at [https://lobste.rs](https://lob
Loading development environment (Rails 3.2.6)
irb(main):001:0> u = User.new(:username => "test", :email => "test@example.com", :password => "test")
irb(main):002:0> u.is_admin = true
irb(main):002:0> u.is_moderator = true
irb(main):003:0> u.save
irb(main):004:0> t = Tag.new

View file

@ -626,7 +626,8 @@ div#story_preview {
div#story_box input#story_url {
width: 508px;
}
div#story_box input#story_title {
div#story_box input#story_title,
div#story_box input#story_moderation_reason {
width: 600px;
}
div#story_box #story_tags_a {

View file

@ -70,7 +70,7 @@ class CommentsController < ApplicationController
def delete
if !((comment = Comment.find_by_short_id(params[:comment_id])) &&
comment.is_editable_by_user?(@user))
comment.is_deletable_by_user?(@user))
return render :text => "can't find comment", :status => 400
end
@ -175,9 +175,8 @@ class CommentsController < ApplicationController
@title = "Newest Comments"
@cur_url = "/comments"
@comments = Comment.find(:all, :conditions => "is_deleted = 0 AND " +
"is_moderated = 0", :order => "created_at DESC", :limit => 20,
:include => [ :user, :story ])
@comments = Comment.find(:all, :conditions => "is_deleted = 0",
:order => "created_at DESC", :limit => 20, :include => [ :user, :story ])
if @user
@votes = Vote.comment_votes_by_user_for_comment_ids_hash(@user.id,

View file

@ -74,7 +74,7 @@ private
end
def _find_stories_for_user_and_tag_and_newest(user, tag = nil, newest = false)
conds = [ "is_expired = 0 AND is_moderated = 0 " ]
conds = [ "is_expired = 0 " ]
if user && !newest
# exclude downvoted items

View file

@ -0,0 +1,13 @@
class ModerationsController < ApplicationController
def index
@pages = Moderation.count
@page = params[:page] ? params[:page].to_i : 0
if @page < 1
@page = 1
end
@moderations = Moderation.order("id desc").limit(50).offset((@page - 1) *
50).all
end
end

View file

@ -45,10 +45,10 @@ class StoriesController < ApplicationController
return redirect_to "/"
end
if @user.is_admin? && @user.id != @story.user_id
@story.is_moderated = true
else
@story.is_expired = true
@story.is_expired = true
if @user.is_moderator? && @user.id != @story.user_id
@story.editor_user_id = @user.id
end
@story.save(:validate => false)
@ -184,7 +184,7 @@ class StoriesController < ApplicationController
end
@story.is_expired = false
@story.is_moderated = false
@story.editor_user_id = @user.id
@story.save(:validate => false)
redirect_to @story.comments_url
@ -197,6 +197,7 @@ class StoriesController < ApplicationController
end
@story.is_expired = false
@story.editor_user_id = @user.id
if @story.update_attributes(params[:story].except(:url))
return redirect_to @story.comments_url
@ -244,7 +245,7 @@ class StoriesController < ApplicationController
private
def find_story
if @user.is_admin?
if @user.is_moderator?
@story = Story.find_by_short_id(params[:story_id] || params[:id])
else
@story = Story.find_by_user_id_and_short_id(@user.id,

View file

@ -6,7 +6,7 @@ class Comment < ActiveRecord::Base
belongs_to :parent_comment,
:class_name => "Comment"
attr_accessible :comment
attr_accessible :comment, :moderation_reason
attr_accessor :parent_comment_short_id, :current_vote, :previewing,
:indent_level, :highlighted
@ -24,10 +24,10 @@ class Comment < ActiveRecord::Base
has "(upvotes - downvotes)", :as => :score, :type => :integer,
:sortable => true
has is_moderated, is_deleted
has is_deleted
has created_at
where "is_moderated = 0 AND is_deleted = 0"
where "is_deleted = 0"
end
validate do
@ -81,7 +81,7 @@ class Comment < ActiveRecord::Base
end
def is_gone?
is_deleted? || is_moderated?
is_deleted?
end
def mark_submitter
@ -112,10 +112,16 @@ class Comment < ActiveRecord::Base
def delete_for_user(user)
Comment.record_timestamps = false
if user.is_admin? && user.id != self.user_id
self.is_deleted = true
if user.is_moderator? && user.id != self.user_id
self.is_moderated = true
else
self.is_deleted = true
m = Moderation.new
m.comment_id = self.id
m.moderator_user_id = user.id
m.action = "deleted comment"
m.save
end
self.save(:validate => false)
@ -127,9 +133,18 @@ class Comment < ActiveRecord::Base
def undelete_for_user(user)
Comment.record_timestamps = false
self.is_moderated = false
self.is_deleted = false
if user.is_moderator? && user.id != self.user_id
self.is_moderated = true
m = Moderation.new
m.comment_id = self.id
m.moderator_user_id = user.id
m.action = "undeleted comment"
m.save
end
self.save(:validate => false)
Comment.record_timestamps = true
@ -233,7 +248,7 @@ class Comment < ActiveRecord::Base
if c.is_gone?
if ordered[x + 1] && (ordered[x + 1].indent_level > c.indent_level)
# we have child comments, so we must stay
elsif user && (user.is_admin? || c.user_id == user.id)
elsif user && (user.is_moderator? || c.user_id == user.id)
# admins and authors should be able to see their deleted comments
else
# drop this one
@ -248,9 +263,7 @@ class Comment < ActiveRecord::Base
end
def is_editable_by_user?(user)
if user && user.is_admin?
return true
elsif user && user.id == self.user_id
if user && user.id == self.user_id
if self.is_moderated?
return false
else
@ -262,8 +275,18 @@ class Comment < ActiveRecord::Base
end
end
def is_deletable_by_user?(user)
if user && user.is_moderator?
return true
elsif user && user.id == self.user_id
return true
else
return false
end
end
def is_undeletable_by_user?(user)
if user && user.is_admin?
if user && user.is_moderator?
return true
elsif user && user.id == self.user_id && !self.is_moderated?
return true

10
app/models/moderation.rb Normal file
View file

@ -0,0 +1,10 @@
class Moderation < ActiveRecord::Base
belongs_to :moderator,
:class_name => "User",
:foreign_key => "moderator_user_id"
belongs_to :story
belongs_to :comment
belongs_to :user
attr_accessible nil
end

View file

@ -16,10 +16,12 @@ class Story < ActiveRecord::Base
attr_accessor :_comment_count
attr_accessor :vote, :already_posted_story, :fetched_content, :previewing
attr_accessor :new_tags, :tags_to_add, :tags_to_delete
attr_accessor :editor_user_id, :moderation_reason
attr_accessible :title, :description, :tags_a
attr_accessible :title, :description, :tags_a, :moderation_reason
before_create :assign_short_id
before_save :log_moderation
after_create :mark_submitter
after_save :deal_with_tags
@ -31,7 +33,7 @@ class Story < ActiveRecord::Base
indexes tags(:tag), :as => :tags
has created_at, :sortable => true
has hotness, is_moderated, is_expired
has hotness, is_expired
has "(upvotes - downvotes)", :as => :score, :type => :integer,
:sortable => true
@ -40,7 +42,7 @@ class Story < ActiveRecord::Base
:tags => 5,
}
where "is_moderated = 0 AND is_expired = 0"
where "is_expired = 0"
end
validate do
@ -118,6 +120,37 @@ class Story < ActiveRecord::Base
end
end
def log_moderation
if self.new_record? || self.editor_user_id == self.user_id
return
end
m = Moderation.new
m.moderator_user_id = self.editor_user_id
m.story_id = self.id
if self.changes["is_expired"] && self.is_expired?
m.action = "deleted story"
elsif self.changes["is_expired"] && !self.is_expired?
m.action = "undeleted story"
else
actions = self.changes.map{|k,v| "changed #{k} from #{v[0].inspect} " <<
"to #{v[1].inspect}" }
if (old_tags = self.tags.map{|t| t.tag }) != self.tags_a
actions.push "changed tags from \"#{old_tags.join(", ")}\" to " <<
"\"#{self.tags_a.join(", ")}\""
end
m.action = actions.join(", ")
end
m.reason = self.moderation_reason
m.save
self.is_moderated = true
end
def give_upvote_or_downvote_and_recalculate_hotness!(upvote, downvote)
self.upvotes += upvote.to_i
self.downvotes += downvote.to_i
@ -236,7 +269,7 @@ class Story < ActiveRecord::Base
def tags_a=(new_tags)
self.tags_to_delete = []
self.tags_to_add = []
self.new_tags = new_tags
self.new_tags = new_tags.reject{|t| t.blank? }
self.tags.each do |tag|
if !new_tags.include?(tag.tag)
@ -252,7 +285,7 @@ class Story < ActiveRecord::Base
end
end
@_tags_a = new_tags
@_tags_a = self.new_tags
end
def url=(u)
@ -286,7 +319,7 @@ class Story < ActiveRecord::Base
end
def is_editable_by_user?(user)
if user && user.is_admin?
if user && user.is_moderator?
return true
elsif user && user.id == self.user_id
if self.is_moderated?
@ -300,7 +333,7 @@ class Story < ActiveRecord::Base
end
def is_undeletable_by_user?(user)
if user && user.is_admin?
if user && user.is_moderator?
return true
elsif user && user.id == self.user_id && !self.is_moderated?
return true
@ -310,7 +343,7 @@ class Story < ActiveRecord::Base
end
def can_be_seen_by_user?(user)
if is_gone? && !(user && (user.is_admin? || user.id == self.user_id))
if is_gone? && !(user && (user.is_moderator? || user.id == self.user_id))
return false
end
@ -318,12 +351,11 @@ class Story < ActiveRecord::Base
end
def is_gone?
is_expired? || is_moderated?
is_expired?
end
def update_comment_count!
Keystore.put("story:#{self.id}:comment_count",
Comment.where(:story_id => self.id, :is_moderated => 0,
:is_deleted => 0).count)
Comment.where(:story_id => self.id, :is_deleted => 0).count)
end
end

View file

@ -48,7 +48,7 @@ class="comment <%= comment.current_vote ? (comment.current_vote[:vote] == 1 ?
<% if comment.is_gone? && comment.is_undeletable_by_user?(@user) %>
|
<a class="comment_undeletor">undelete</a>
<% elsif !comment.is_gone? && comment.is_editable_by_user?(@user) %>
<% elsif !comment.is_gone? && comment.is_deletable_by_user?(@user) %>
|
<a class="comment_deletor">delete</a>
<% end %>
@ -73,8 +73,8 @@ class="comment <%= comment.current_vote ? (comment.current_vote[:vote] == 1 ?
<% if comment.is_gone? %>
<p>
<span class="na">
[Comment removed by <%= comment.is_deleted? ? "author" :
"moderator" %>]
[Comment removed by <%= comment.is_moderated? ? "moderator" :
"author" %>]
</span>
</p>
<% else %>

View file

@ -16,5 +16,5 @@
<% end %>
<a href="<%= @tag ? "/t/#{@tag.tag}" : (@newest ? "/newest" : "")
%>/page/<%= @page + 1 %>">Page <%= @page + 1 %> &gt;&gt;</a>
</div>
<% end %>
<% end %>
</div>

View file

@ -0,0 +1,54 @@
<div class="box wide">
<div class="legend">
Moderation Log
</div>
<table class="data" width="100%" cellspacing=0>
<tr>
<th width="20%">Date/Time</th>
<th width="15%">Moderator</th>
<th width="65%">Story/Comment/User, Changes, Reason</th>
</tr>
<% bit = 0 %>
<% @moderations.each do |mod| %>
<tr class="row<%= bit %> nobottom">
<td><%= mod.created_at.strftime("%Y-%m-%d %H:%M:%S") %></td>
<td><a href="/messages?to=<%= mod.moderator.try(:username) %>"><%=
mod.moderator.try(:username) %></a></td>
<td><% if mod.story %>
<a href="<%= mod.story.comments_url %>">Story: <%= mod.story.title
%></a>
<% elsif mod.comment %>
<a href="<%= mod.comment.url %>">Comment on <%=
mod.comment.story.title %></a>
<% elsif mod.user %>
User <%= mod.user.try(:username) %>
<% end %></td>
</tr>
<tr class="row<%= bit %> <%= mod.reason.present?? "nobottom" : "" %>">
<td colspan=2></td>
<td>Action: <em><%= mod.action %></em></td>
</tr>
<% if mod.reason.present? %>
<tr class="row<%= bit %>">
<td colspan=2></td>
<td>Reason: <em><%= mod.reason %></em></td>
</tr>
<% end %>
<% bit = (bit == 1 ? 0 : 1) %>
<% end %>
</table>
<div class="morelink">
<% if @page && @page > 1 %>
<a href="/moderations/page/<%= @page - 1 %>">&lt;&lt; Page
<%= @page - 1 %></a>
<% end %>
<% if @page && @page > 1 %>
|
<a href="/moderations/page/<%= @page + 1 %>">Page <%= @page + 1
%> &gt;&gt;</a>
<% end %>
</div>
</div>

View file

@ -22,8 +22,8 @@ class="story <%= story.vote == 1 ? "upvoted" : (story.vote == -1 ?
<a href="<%= story.url_or_comments_url %>"><%= story.title %></a>
<% end %>
<% if story.is_gone? %>
[Story removed by <%= story.is_expired? ? "original submitter" :
"moderator" %>]
[Story removed by <%= story.is_moderated? ? "moderator" :
"original submitter" %>]
<% end %>
</span>
<% if story.can_be_seen_by_user?(@user) %>

View file

@ -8,6 +8,16 @@
<%= render :partial => "stories/form", :locals => { :story => @story,
:f => f } %>
<% if @story.user_id != @user.id %>
<div class="box">
<div class="boxline">
<%= f.label :moderation_reason, "Mod Reason:",
:class => "required" %>
<%= f.text_field :moderation_reason, :autocomplete => "off" %>
</div>
</div>
<% end %>
<p></p>
<div class="box">

View file

@ -8,7 +8,8 @@
<label class="required">Status:</label>
<span class="d">
Active <%= @showing_user.is_admin? ? "administrator" : "user" %>
Active <%= @showing_user.is_admin? ? "administrator" :
(@showing_user.is_moderator? ? "moderator" : "user") %>
</span>
<br>

View file

@ -71,4 +71,7 @@ Lobsters::Application.routes.draw do
post "/invitations" => "invitations#create"
get "/invitations/:invitation_code" => "signup#invited"
get "/moderations" => "moderations#index"
get "/moderations/page/:page" => "moderations#index"
end

View file

@ -0,0 +1,18 @@
class AddModerationLog < ActiveRecord::Migration
def up
add_column "users", "is_moderator", :boolean, :default => false
create_table "moderations" do |t|
t.timestamps
t.integer "moderator_user_id"
t.integer "story_id"
t.integer "comment_id"
t.integer "user_id"
t.text "action"
t.text "reason"
end
end
def down
end
end

View file

@ -11,7 +11,7 @@
#
# It's strongly recommended to check this file into your version control system.
ActiveRecord::Schema.define(:version => 20120816203248) do
ActiveRecord::Schema.define(:version => 20120902143549) do
create_table "comments", :force => true do |t|
t.datetime "created_at", :null => false
@ -65,6 +65,17 @@ ActiveRecord::Schema.define(:version => 20120816203248) do
add_index "messages", ["short_id"], :name => "random_hash", :unique => true
create_table "moderations", :force => true do |t|
t.datetime "created_at", :null => false
t.datetime "updated_at", :null => false
t.integer "moderator_user_id"
t.integer "story_id"
t.integer "comment_id"
t.integer "user_id"
t.text "action"
t.text "reason"
end
create_table "stories", :force => true do |t|
t.datetime "created_at"
t.integer "user_id"
@ -124,6 +135,7 @@ ActiveRecord::Schema.define(:version => 20120816203248) do
t.string "pushover_device"
t.boolean "email_messages", :default => true
t.boolean "pushover_messages", :default => true
t.boolean "is_moderator", :default => false
end
add_index "users", ["session_token"], :name => "session_hash", :unique => true