From e6c74e82510f41b45deb00514f5624ff1ddf2e6d Mon Sep 17 00:00:00 2001 From: joshua stein Date: Sun, 2 Sep 2012 09:50:07 -0500 Subject: [PATCH] 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. --- README.md | 1 + app/assets/stylesheets/application.css | 3 +- app/controllers/comments_controller.rb | 7 ++- app/controllers/home_controller.rb | 2 +- app/controllers/moderations_controller.rb | 13 +++++ app/controllers/stories_controller.rb | 13 ++--- app/models/comment.rb | 49 ++++++++++++----- app/models/moderation.rb | 10 ++++ app/models/story.rb | 54 +++++++++++++++---- app/views/comments/_comment.html.erb | 6 +-- app/views/home/index.html.erb | 4 +- app/views/moderations/index.html.erb | 54 +++++++++++++++++++ app/views/stories/_listdetail.html.erb | 4 +- app/views/stories/edit.html.erb | 10 ++++ app/views/users/show.html.erb | 3 +- config/routes.rb | 3 ++ .../20120902143549_add_moderation_log.rb | 18 +++++++ db/schema.rb | 14 ++++- 18 files changed, 223 insertions(+), 45 deletions(-) create mode 100644 app/controllers/moderations_controller.rb create mode 100644 app/models/moderation.rb create mode 100644 app/views/moderations/index.html.erb create mode 100644 db/migrate/20120902143549_add_moderation_log.rb diff --git a/README.md b/README.md index 393eafe..378457f 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/app/assets/stylesheets/application.css b/app/assets/stylesheets/application.css index 28fe7dd..f86c6f1 100644 --- a/app/assets/stylesheets/application.css +++ b/app/assets/stylesheets/application.css @@ -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 { diff --git a/app/controllers/comments_controller.rb b/app/controllers/comments_controller.rb index 155ae8e..62cb960 100644 --- a/app/controllers/comments_controller.rb +++ b/app/controllers/comments_controller.rb @@ -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, diff --git a/app/controllers/home_controller.rb b/app/controllers/home_controller.rb index e305dc8..50ff482 100644 --- a/app/controllers/home_controller.rb +++ b/app/controllers/home_controller.rb @@ -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 diff --git a/app/controllers/moderations_controller.rb b/app/controllers/moderations_controller.rb new file mode 100644 index 0000000..5447e6b --- /dev/null +++ b/app/controllers/moderations_controller.rb @@ -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 diff --git a/app/controllers/stories_controller.rb b/app/controllers/stories_controller.rb index dc01efc..7512eda 100644 --- a/app/controllers/stories_controller.rb +++ b/app/controllers/stories_controller.rb @@ -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, diff --git a/app/models/comment.rb b/app/models/comment.rb index 4bc2da7..c5d9810 100644 --- a/app/models/comment.rb +++ b/app/models/comment.rb @@ -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 diff --git a/app/models/moderation.rb b/app/models/moderation.rb new file mode 100644 index 0000000..dd7fa8a --- /dev/null +++ b/app/models/moderation.rb @@ -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 diff --git a/app/models/story.rb b/app/models/story.rb index 8419441..3ecd333 100644 --- a/app/models/story.rb +++ b/app/models/story.rb @@ -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 diff --git a/app/views/comments/_comment.html.erb b/app/views/comments/_comment.html.erb index a9ddb93..63afc5e 100644 --- a/app/views/comments/_comment.html.erb +++ b/app/views/comments/_comment.html.erb @@ -48,7 +48,7 @@ class="comment <%= comment.current_vote ? (comment.current_vote[:vote] == 1 ? <% if comment.is_gone? && comment.is_undeletable_by_user?(@user) %> | undelete - <% elsif !comment.is_gone? && comment.is_editable_by_user?(@user) %> + <% elsif !comment.is_gone? && comment.is_deletable_by_user?(@user) %> | delete <% end %> @@ -73,8 +73,8 @@ class="comment <%= comment.current_vote ? (comment.current_vote[:vote] == 1 ? <% if comment.is_gone? %>

- [Comment removed by <%= comment.is_deleted? ? "author" : - "moderator" %>] + [Comment removed by <%= comment.is_moderated? ? "moderator" : + "author" %>]

<% else %> diff --git a/app/views/home/index.html.erb b/app/views/home/index.html.erb index 2ce2e03..f18e484 100644 --- a/app/views/home/index.html.erb +++ b/app/views/home/index.html.erb @@ -16,5 +16,5 @@ <% end %> /page/<%= @page + 1 %>">Page <%= @page + 1 %> >> - -<% end %> + <% end %> + diff --git a/app/views/moderations/index.html.erb b/app/views/moderations/index.html.erb new file mode 100644 index 0000000..e4445b3 --- /dev/null +++ b/app/views/moderations/index.html.erb @@ -0,0 +1,54 @@ +
+
+ Moderation Log +
+ + + + + + + + <% bit = 0 %> + <% @moderations.each do |mod| %> + + + + + + "> + + + + <% if mod.reason.present? %> + + + + + <% end %> + <% bit = (bit == 1 ? 0 : 1) %> + <% end %> +
Date/TimeModeratorStory/Comment/User, Changes, Reason
<%= mod.created_at.strftime("%Y-%m-%d %H:%M:%S") %><%= + mod.moderator.try(:username) %><% if mod.story %> + Story: <%= mod.story.title + %> + <% elsif mod.comment %> + Comment on <%= + mod.comment.story.title %> + <% elsif mod.user %> + User <%= mod.user.try(:username) %> + <% end %>
Action: <%= mod.action %>
Reason: <%= mod.reason %>
+ + +
diff --git a/app/views/stories/_listdetail.html.erb b/app/views/stories/_listdetail.html.erb index 4be74e7..7995adb 100644 --- a/app/views/stories/_listdetail.html.erb +++ b/app/views/stories/_listdetail.html.erb @@ -22,8 +22,8 @@ class="story <%= story.vote == 1 ? "upvoted" : (story.vote == -1 ? <%= story.title %> <% end %> <% if story.is_gone? %> - [Story removed by <%= story.is_expired? ? "original submitter" : - "moderator" %>] + [Story removed by <%= story.is_moderated? ? "moderator" : + "original submitter" %>] <% end %> <% if story.can_be_seen_by_user?(@user) %> diff --git a/app/views/stories/edit.html.erb b/app/views/stories/edit.html.erb index b71e14a..cccab05 100644 --- a/app/views/stories/edit.html.erb +++ b/app/views/stories/edit.html.erb @@ -8,6 +8,16 @@ <%= render :partial => "stories/form", :locals => { :story => @story, :f => f } %> + <% if @story.user_id != @user.id %> +
+
+ <%= f.label :moderation_reason, "Mod Reason:", + :class => "required" %> + <%= f.text_field :moderation_reason, :autocomplete => "off" %> +
+
+ <% end %> +

diff --git a/app/views/users/show.html.erb b/app/views/users/show.html.erb index 95e4978..4f7dc28 100644 --- a/app/views/users/show.html.erb +++ b/app/views/users/show.html.erb @@ -8,7 +8,8 @@ - Active <%= @showing_user.is_admin? ? "administrator" : "user" %> + Active <%= @showing_user.is_admin? ? "administrator" : + (@showing_user.is_moderator? ? "moderator" : "user") %>
diff --git a/config/routes.rb b/config/routes.rb index 5e7336c..93bd032 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -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 diff --git a/db/migrate/20120902143549_add_moderation_log.rb b/db/migrate/20120902143549_add_moderation_log.rb new file mode 100644 index 0000000..33ef6c0 --- /dev/null +++ b/db/migrate/20120902143549_add_moderation_log.rb @@ -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 diff --git a/db/schema.rb b/db/schema.rb index cd09266..bae7fcd 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -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