Merge branch 'master' into moderation
authorAndy Allan <git@gravitystorm.co.uk>
Wed, 28 Feb 2018 07:55:28 +0000 (15:55 +0800)
committerAndy Allan <git@gravitystorm.co.uk>
Wed, 28 Feb 2018 07:55:28 +0000 (15:55 +0800)
38 files changed:
.rubocop_todo.yml
Gemfile
Gemfile.lock
app/assets/stylesheets/common.scss
app/controllers/issue_comments_controller.rb [new file with mode: 0644]
app/controllers/issues_controller.rb [new file with mode: 0644]
app/controllers/reports_controller.rb [new file with mode: 0644]
app/helpers/issues_helper.rb [new file with mode: 0644]
app/models/issue.rb [new file with mode: 0644]
app/models/issue_comment.rb [new file with mode: 0644]
app/models/report.rb [new file with mode: 0644]
app/models/user.rb
app/views/browse/note.html.erb
app/views/diary_entry/_diary_comment.html.erb
app/views/diary_entry/_diary_entry.html.erb
app/views/issues/_comments.html.erb [new file with mode: 0644]
app/views/issues/_reports.html.erb [new file with mode: 0644]
app/views/issues/index.html.erb [new file with mode: 0644]
app/views/issues/show.html.erb [new file with mode: 0644]
app/views/layouts/_header.html.erb
app/views/reports/new.html.erb [new file with mode: 0644]
app/views/user/view.html.erb
config/locales/en.yml
config/routes.rb
db/migrate/20160822153055_create_issues_and_reports.rb [new file with mode: 0644]
db/structure.sql
test/controllers/issue_comments_controller_test.rb [new file with mode: 0644]
test/controllers/issues_controller_test.rb [new file with mode: 0644]
test/controllers/reports_controller_test.rb [new file with mode: 0644]
test/factories/issues.rb [new file with mode: 0644]
test/factories/reports.rb [new file with mode: 0644]
test/models/issue_comment_test.rb [new file with mode: 0644]
test/models/issue_test.rb [new file with mode: 0644]
test/models/report_test.rb [new file with mode: 0644]
test/system/issues_test.rb [new file with mode: 0644]
test/system/report_anonymous_note_test.rb [new file with mode: 0644]
test/system/report_diary_entry_test.rb [new file with mode: 0644]
test/test_helper.rb

index 2c8d29eb887fac048d55a61cfcdb3d34e5138fa3..5846de44f47d987a1bf16d6f67a19dd7227617e2 100644 (file)
@@ -67,7 +67,7 @@ Metrics/AbcSize:
 # Offense count: 41
 # Configuration parameters: CountComments, ExcludedMethods.
 Metrics/BlockLength:
-  Max: 240
+  Max: 250
 
 # Offense count: 12
 # Configuration parameters: CountBlocks.
diff --git a/Gemfile b/Gemfile
index 09c662e4a802a926a42cffb14d913922ff16acfd..a9a6cb4b6d1466d65c41c05d8037a695e8900b54 100644 (file)
--- a/Gemfile
+++ b/Gemfile
@@ -70,6 +70,9 @@ gem "omniauth-windowslive"
 # Markdown formatting support
 gem "redcarpet"
 
+# For status transitions of Issues
+gem "aasm"
+
 # Load libxml support for XML parsing and generation
 gem "libxml-ruby", ">= 2.0.5", :require => "libxml"
 
index bbfb73c5a6e67f604d2ae177ba56b6750c5143d3..02bb3e5fdbeaf3634243e6ca46721cdddf7caaa2 100644 (file)
@@ -2,6 +2,7 @@ GEM
   remote: https://rubygems.org/
   specs:
     SystemTimer (1.2.3)
+    aasm (4.1.0)
     actioncable (5.1.5)
       actionpack (= 5.1.5)
       nio4r (~> 2.0)
@@ -354,6 +355,7 @@ PLATFORMS
 
 DEPENDENCIES
   SystemTimer (>= 1.1.3)
+  aasm
   actionpack-page_caching
   annotate
   autoprefixer-rails
index 4353e5e659d85628ff9a8f175d291ffdd4465652..665ea8c2446ccff94f5e6c516315a0d6dc02876e 100644 (file)
@@ -2818,3 +2818,44 @@ input.richtext_title[type="text"] {
     display: none;
   }
 }
+
+.read-reports {
+  background: #eee;
+  opacity: 0.7;
+}
+
+.report-related-block {
+  display:inline-block;
+}
+
+.report-block {
+  width:475px;
+  float:left;
+  margin-right:100px;
+}
+
+.related-block{
+  width:280px;
+  float:right;
+}
+
+.issue-comments {
+  width:475px;
+}
+
+.report-disclaimer {
+  background: #fff1f0;
+  color: #d85030;
+  border-color: rgba(216, 80, 48, 0.3);
+  padding: 5px;
+  margin-bottom: $lineheight;
+
+  ul {
+    padding-left: $lineheight;
+    margin-bottom: 0;
+
+    li {
+      list-style: disc;
+    }
+  }
+}
diff --git a/app/controllers/issue_comments_controller.rb b/app/controllers/issue_comments_controller.rb
new file mode 100644 (file)
index 0000000..448b2a5
--- /dev/null
@@ -0,0 +1,37 @@
+class IssueCommentsController < ApplicationController
+  layout "site"
+
+  before_action :authorize_web
+  before_action :require_user
+  before_action :check_permission
+
+  def create
+    @issue = Issue.find(params[:issue_id])
+    comment = @issue.comments.build(issue_comment_params)
+    comment.user = current_user
+    comment.save!
+    notice = t("issues.comment.comment_created")
+    reassign_issue(@issue) if params[:reassign]
+    redirect_to @issue, :notice => notice
+  end
+
+  private
+
+  def issue_comment_params
+    params.require(:issue_comment).permit(:body)
+  end
+
+  def check_permission
+    unless current_user.administrator? || current_user.moderator?
+      flash[:error] = t("application.require_admin.not_an_admin")
+      redirect_to root_path
+    end
+  end
+
+  # This sort of assumes there are only two roles
+  def reassign_issue(issue)
+    role = (Issue::ASSIGNED_ROLES - [issue.assigned_role]).first
+    issue.assigned_role = role
+    issue.save!
+  end
+end
diff --git a/app/controllers/issues_controller.rb b/app/controllers/issues_controller.rb
new file mode 100644 (file)
index 0000000..90fd030
--- /dev/null
@@ -0,0 +1,132 @@
+class IssuesController < ApplicationController
+  layout "site"
+
+  before_action :authorize_web
+  before_action :require_user
+  before_action :set_issues
+  before_action :check_permission
+  before_action :find_issue, :only => [:show, :resolve, :reopen, :ignore]
+
+  def index
+    @title = t ".title"
+
+    if current_user.moderator?
+      @issue_types = @moderator_issues
+      @users = User.joins(:roles).where(:user_roles => { :role => "moderator" })
+    else
+      @issue_types = @admin_issues
+      @users = User.joins(:roles).where(:user_roles => { :role => "administrator" })
+    end
+
+    @issues = Issue.where(:assigned_role => current_user.roles.map(&:role))
+
+    # If search
+    if params[:search_by_user] && params[:search_by_user].present?
+      @find_user = User.find_by(:display_name => params[:search_by_user])
+      if @find_user
+        @issues = @issues.where(:reported_user_id => @find_user.id)
+      else
+        notice = t("issues.index.user_not_found")
+      end
+    end
+
+    if params[:status] && params[:status][0].present?
+      @issues = @issues.where(:status => params[:status][0])
+    end
+
+    if params[:issue_type] && params[:issue_type][0].present?
+      @issues = @issues.where(:reportable_type => params[:issue_type][0])
+    end
+
+    if params[:last_updated_by] && params[:last_updated_by][0].present?
+      last_updated_by = params[:last_updated_by][0].to_s == "nil" ? nil : params[:last_updated_by][0].to_i
+      @issues = @issues.where(:updated_by => last_updated_by)
+    end
+
+    redirect_to issues_path, :notice => notice if notice
+  end
+
+  def show
+    @read_reports = @issue.read_reports
+    @unread_reports = @issue.unread_reports
+    @comments = @issue.comments
+    @related_issues = @issue.reported_user.issues.where(:assigned_role => current_user.roles.map(&:role)) if @issue.reported_user
+    @new_comment = IssueComment.new(:issue => @issue)
+  end
+
+  # Status Transistions
+  def resolve
+    if @issue.resolve
+      @issue.save!
+      redirect_to @issue, :notice => t("issues.resolved")
+    else
+      render :show
+    end
+  end
+
+  def ignore
+    if @issue.ignore
+      @issue.updated_by = current_user.id
+      @issue.save!
+      redirect_to @issue, :notice => t("issues.ignored")
+    else
+      render :show
+    end
+  end
+
+  def reopen
+    if @issue.reopen
+      @issue.updated_by = current_user.id
+      @issue.save!
+      redirect_to @issue, :notice => t("issues.reopened")
+    else
+      render :show
+    end
+  end
+
+  private
+
+  def set_issues
+    @admin_issues = %w[DiaryEntry DiaryComment User]
+    @moderator_issues = %w[Note]
+  end
+
+  def check_if_updated
+    if @issue.reportable && (@issue.ignored? || @issue.resolved?) && @issue.reportable.has_attribute?(:updated_by) && @issue.reportable.updated_at > @last_report.updated_at
+      true
+    else
+      false
+    end
+  end
+
+  def find_issue
+    @issue = Issue.find(params[:id])
+  end
+
+  def check_permission
+    unless current_user.administrator? || current_user.moderator?
+      flash[:error] = t("application.require_admin.not_an_admin")
+      redirect_to root_path
+    end
+  end
+
+  def issue_params
+    params[:issue].permit(:reportable_id, :reportable_type)
+  end
+
+  def report_params
+    params[:report].permit(:details)
+  end
+
+  def issue_comment_params
+    params.require(:issue_comment).permit(:body)
+  end
+
+  def sort_column
+    Issue.column_names.include?(params[:sort]) ? params[:sort] : "status"
+  end
+
+  def sort_direction
+    %w[asc desc].include?(params[:direction]) ? params[:direction] : "asc"
+  end
+end
diff --git a/app/controllers/reports_controller.rb b/app/controllers/reports_controller.rb
new file mode 100644 (file)
index 0000000..5d04427
--- /dev/null
@@ -0,0 +1,36 @@
+class ReportsController < ApplicationController
+  layout "site"
+
+  before_action :authorize_web
+  before_action :require_user
+
+  def new
+    if create_new_report_params.present?
+      @report = Report.new
+      @report.issue = Issue.find_or_initialize_by(create_new_report_params)
+    end
+  end
+
+  def create
+    @report = current_user.reports.new(report_params)
+    @report.issue = Issue.find_or_initialize_by(:reportable_id => params[:report][:issue][:reportable_id], :reportable_type => params[:report][:issue][:reportable_type])
+
+    if @report.save
+      @report.issue.save
+      @report.issue.reopen! unless @report.issue.open?
+      redirect_to root_path, :notice => t("issues.create.successful_report")
+    else
+      redirect_to new_report_path(:reportable_type => @report.issue.reportable_type, :reportable_id => @report.issue.reportable_id), :notice => t("issues.create.provide_details")
+    end
+  end
+
+  private
+
+  def create_new_report_params
+    params.permit(:reportable_id, :reportable_type)
+  end
+
+  def report_params
+    params[:report].permit(:details, :category)
+  end
+end
diff --git a/app/helpers/issues_helper.rb b/app/helpers/issues_helper.rb
new file mode 100644 (file)
index 0000000..3c13c65
--- /dev/null
@@ -0,0 +1,27 @@
+module IssuesHelper
+  def reportable_url(reportable)
+    case reportable
+    when DiaryEntry
+      url_for(:controller => reportable.class.name.underscore, :action => :view, :display_name => reportable.user.display_name, :id => reportable.id)
+    when User
+      url_for(:controller => reportable.class.name.underscore, :action => :view, :display_name => reportable.display_name)
+    when DiaryComment
+      url_for(:controller => reportable.diary_entry.class.name.underscore, :action => :view, :display_name => reportable.diary_entry.user.display_name, :id => reportable.diary_entry.id, :anchor => "comment#{reportable.id}")
+    when Note
+      url_for(:controller => :browse, :action => :note, :id => reportable.id)
+    end
+  end
+
+  def reportable_title(reportable)
+    case reportable
+    when DiaryEntry
+      reportable.title
+    when User
+      reportable.display_name
+    when DiaryComment
+      "#{reportable.diary_entry.title}, Comment id ##{reportable.id}"
+    when Note
+      "Note ##{reportable.id}"
+    end
+  end
+end
diff --git a/app/models/issue.rb b/app/models/issue.rb
new file mode 100644 (file)
index 0000000..d13f297
--- /dev/null
@@ -0,0 +1,99 @@
+# == Schema Information
+#
+# Table name: issues
+#
+#  id               :integer          not null, primary key
+#  reportable_type  :string           not null
+#  reportable_id    :integer          not null
+#  reported_user_id :integer
+#  status           :integer
+#  assigned_role    :enum             not null
+#  resolved_at      :datetime
+#  resolved_by      :integer
+#  updated_by       :integer
+#  reports_count    :integer          default(0)
+#  created_at       :datetime         not null
+#  updated_at       :datetime         not null
+#
+# Indexes
+#
+#  index_issues_on_reportable_id_and_reportable_type  (reportable_id,reportable_type)
+#  index_issues_on_reported_user_id                   (reported_user_id)
+#  index_issues_on_updated_by                         (updated_by)
+#
+# Foreign Keys
+#
+#  issues_reported_user_id_fkey  (reported_user_id => users.id) ON DELETE => cascade
+#  issues_updated_by_fkey        (updated_by => users.id) ON DELETE => cascade
+#
+
+class Issue < ActiveRecord::Base
+  belongs_to :reportable, :polymorphic => true
+  belongs_to :reported_user, :class_name => "User", :foreign_key => :reported_user_id
+  belongs_to :user_updated, :class_name => "User", :foreign_key => :updated_by
+
+  has_many :reports, :dependent => :destroy
+  has_many :comments, :class_name => "IssueComment", :dependent => :destroy
+
+  validates :reportable_id, :uniqueness => { :scope => [:reportable_type] }
+
+  ASSIGNED_ROLES = %w[administrator moderator].freeze
+  validates :assigned_role, :presence => true, :inclusion => ASSIGNED_ROLES
+
+  before_validation :set_default_assigned_role
+  before_validation :set_reported_user
+
+  # Check if more statuses are needed
+  enum :status => %w[open ignored resolved]
+
+  scope :with_status, ->(issue_status) { where(:status => statuses[issue_status]) }
+
+  def read_reports
+    resolved_at.present? ? reports.where("updated_at < ?", resolved_at) : nil
+  end
+
+  def unread_reports
+    resolved_at.present? ? reports.where("updated_at >= ?", resolved_at) : reports
+  end
+
+  include AASM
+  aasm :column => :status, :no_direct_assignment => true do
+    state :open, :initial => true
+    state :ignored
+    state :resolved
+
+    event :ignore do
+      transitions :from => :open, :to => :ignored
+    end
+
+    event :resolve do
+      transitions :from => :open, :to => :resolved
+      after do
+        self.resolved_at = Time.now.getutc
+      end
+    end
+
+    event :reopen do
+      transitions :from => :resolved, :to => :open
+      transitions :from => :ignored, :to => :open
+    end
+  end
+
+  private
+
+  def set_reported_user
+    self.reported_user = case reportable.class.name
+                         when "User"
+                           reportable
+                         when "Note"
+                           reportable.author
+                         else
+                           reportable.user
+                         end
+  end
+
+  def set_default_assigned_role
+    role = %w[Note].include?(reportable.class.name) ? "moderator" : "administrator"
+    self.assigned_role = role if assigned_role.blank?
+  end
+end
diff --git a/app/models/issue_comment.rb b/app/models/issue_comment.rb
new file mode 100644 (file)
index 0000000..2968078
--- /dev/null
@@ -0,0 +1,30 @@
+# == Schema Information
+#
+# Table name: issue_comments
+#
+#  id         :integer          not null, primary key
+#  issue_id   :integer          not null
+#  user_id    :integer          not null
+#  body       :text             not null
+#  created_at :datetime         not null
+#  updated_at :datetime         not null
+#
+# Indexes
+#
+#  index_issue_comments_on_issue_id  (issue_id)
+#  index_issue_comments_on_user_id   (user_id)
+#
+# Foreign Keys
+#
+#  issue_comments_issue_id_fkey  (issue_id => issues.id) ON DELETE => cascade
+#  issue_comments_user_id        (user_id => users.id) ON DELETE => cascade
+#
+
+class IssueComment < ActiveRecord::Base
+  belongs_to :issue
+  belongs_to :user
+
+  validates :body, :presence => true
+  validates :user, :presence => true
+  validates :issue, :presence => true
+end
diff --git a/app/models/report.rb b/app/models/report.rb
new file mode 100644 (file)
index 0000000..fcfa61b
--- /dev/null
@@ -0,0 +1,40 @@
+# == Schema Information
+#
+# Table name: reports
+#
+#  id         :integer          not null, primary key
+#  issue_id   :integer
+#  user_id    :integer
+#  details    :text             not null
+#  category   :string           not null
+#  created_at :datetime         not null
+#  updated_at :datetime         not null
+#
+# Indexes
+#
+#  index_reports_on_issue_id  (issue_id)
+#  index_reports_on_user_id   (user_id)
+#
+# Foreign Keys
+#
+#  reports_issue_id_fkey  (issue_id => issues.id) ON DELETE => cascade
+#  reports_user_id_fkey   (user_id => users.id) ON DELETE => cascade
+#
+
+class Report < ActiveRecord::Base
+  belongs_to :issue, :counter_cache => true
+  belongs_to :user
+
+  validates :details, :presence => true
+  validates :category, :presence => true
+
+  def self.categories_for(reportable)
+    case reportable.class.name
+    when "DiaryEntry" then %w[spam offensive threat other]
+    when "DiaryComment" then %w[spam offensive threat other]
+    when "User" then %w[spam offensive threat vandal other]
+    when "Note" then %w[spam vandalism personal abusive other]
+    else %w[other]
+    end
+  end
+end
index 1cd8c6d89f439651d5b88f35b04ef2dda887a4a6..2d51b5af56915296825acb9ca33d1437cf1f5ceb 100644 (file)
@@ -73,6 +73,12 @@ class User < ActiveRecord::Base
 
   has_many :roles, :class_name => "UserRole"
 
+  has_many :issues, :class_name => "Issue", :foreign_key => :reported_user_id
+  has_one :issue, :class_name => "Issue", :foreign_key => :updated_by
+  has_many :issue_comments
+
+  has_many :reports
+
   scope :visible, -> { where(:status => %w[pending active confirmed]) }
   scope :active, -> { where(:status => %w[active confirmed]) }
   scope :identifiable, -> { where(:data_public => true) }
index 1bacd27d6b2dc3db27b476e2dc06d860a1cf78ef..075ac6462de78eaa2da850e9ca517adc0a1655fa 100644 (file)
       <br/>
       <%= note_event(@note.status, @note.closed_at, @note_comments.last.author) %>
     <% end %>
+    <% if current_user && current_user != @note.author %>
+      <%= link_to new_report_url(reportable_id: @note.id, reportable_type: @note.class.name), :title => t('browse.note.report') do %>
+          &nbsp;&#9872;
+      <% end %>
+    <% end %>
   </div>
 
   <% if @note_comments.find { |comment| comment.author.nil? } -%>
index c651c294304e3e6805009ad3a38b69ab5f468839..ae1386fb2a421f50956a5da97c6fa732aa6acc77 100644 (file)
@@ -1,6 +1,13 @@
 <div class="clearfix diary-comment">
   <%= user_thumbnail diary_comment.user %>
-  <p class="deemphasize comment-heading" id="comment<%= diary_comment.id %>"><%= raw(t('diary_entry.diary_comment.comment_from', :link_user => (link_to h(diary_comment.user.display_name), :controller => 'user', :action => 'view', :display_name => diary_comment.user.display_name), :comment_created_at => link_to(l(diary_comment.created_at, :format => :friendly), :anchor => "comment#{diary_comment.id}"))) %></p>
+  <p class="deemphasize comment-heading" id="comment<%= diary_comment.id %>"><%= raw(t('diary_entry.diary_comment.comment_from', :link_user => (link_to h(diary_comment.user.display_name), :controller => 'user', :action => 'view', :display_name => diary_comment.user.display_name), :comment_created_at => link_to(l(diary_comment.created_at, :format => :friendly), :anchor => "comment#{diary_comment.id}"))) %>
+    <% if current_user and diary_comment.user.id != current_user.id %>
+      <%= link_to new_report_url(reportable_id: diary_comment.id, reportable_type: diary_comment.class.name), :title => t('diary_entry.diary_comment.report') do %>
+              &nbsp;&#9872;
+      <% end %>
+    <% end %>
+  </p>
+
   <div class="richtext"><%= diary_comment.body.to_html %></div>
   <%= if_administrator(:span) do %>
     <%= link_to t('diary_entry.diary_comment.hide_link'), hide_diary_comment_path(:display_name => diary_comment.diary_entry.user.display_name, :id => diary_comment.diary_entry.id, :comment => diary_comment.id), :method => :post, :data=> { :confirm => t('diary_entry.diary_comment.confirm') } %>
index 410e13047663bb32439a90da611a646906216a5c..872e31b3aa347c7e27c69b314ea62bbbeb5d347b 100644 (file)
@@ -6,6 +6,12 @@
 
     <h2><%= link_to h(diary_entry.title), :action => 'view', :display_name => diary_entry.user.display_name, :id => diary_entry.id %></h2>
 
+    <% if current_user and diary_entry.user != current_user %>
+           <%= link_to new_report_url(reportable_id: diary_entry.id, reportable_type: diary_entry.class.name), :title => t('diary_entry.diary_entry.report') do %>
+            &nbsp;&#9872;
+          <% end %>
+    <% end %>
+
     <small class='deemphasize'>
       <%= raw(t 'diary_entry.diary_entry.posted_by', :link_user => (link_to h(diary_entry.user.display_name), :controller => 'user', :action => 'view', :display_name => diary_entry.user.display_name), :created => l(diary_entry.created_at, :format => :blog), :language_link => (link_to h(diary_entry.language.name), :controller => 'diary_entry', :action => 'list', :display_name => nil, :language => diary_entry.language_code)) %>
     </small>
diff --git a/app/views/issues/_comments.html.erb b/app/views/issues/_comments.html.erb
new file mode 100644 (file)
index 0000000..ec885aa
--- /dev/null
@@ -0,0 +1,25 @@
+<div class="issue-comments">
+  <% comments.each do |comment| %>
+    <div class="comment">
+      <div style="float:left">
+        <%= link_to user_thumbnail(comment.user), :controller => :user, :action =>:view, :display_name => comment.user.display_name %>
+      </div>
+      <b> <%= link_to comment.user.display_name, :controller => :user, :action =>:view, :display_name => comment.user.display_name %> </b> <br/>
+      <%= comment.body %>
+    </div>
+    <span class="deemphasize">
+      <%= t(".created_at", :datetime => l(comment.created_at.to_datetime, :format => :friendly)) %>
+    </span>
+    <hr>
+  <% end %>
+</div>
+<br/>
+<div class="comment">
+  <%= form_for @new_comment, url: issue_comments_path(@issue) do |f| %>
+  <%= richtext_area :issue_comment, :body, :cols => 10, :rows => 8, :required => true %>
+  <%= label_tag :reassign, t('issues.show.comments.reassign_param') %> <%= check_box_tag :reassign, true %>
+  <br/>
+  <br/>
+  <%= submit_tag 'Submit' %>
+  <% end %>
+</div>
diff --git a/app/views/issues/_reports.html.erb b/app/views/issues/_reports.html.erb
new file mode 100644 (file)
index 0000000..5303ae0
--- /dev/null
@@ -0,0 +1,19 @@
+<% reports.each do |report| %>
+  <div class="reports">
+    <div style="float:left">
+      <%= link_to user_thumbnail(report.user), :controller => :user, :action =>:view, :display_name => report.user.display_name %>
+    </div>
+    <%= t(".reported_by_html", :user_name => report.user.display_name, :user_url => url_for(:controller => :user, :action => :view, :display_name => report.user.display_name)) %> <br/>
+    <span class="deemphasize">
+      <%= t(".updated_at", :datetime => l(report.updated_at.to_datetime, :format => :friendly)) %>
+    </span>
+    <br/>
+    <span class="deemphasize">
+      <%= t ".category", category: report.category %>
+    </span>
+    <br/>
+    <%= report.details %>
+    <br/>
+  </div>
+  <hr>
+<% end %>
diff --git a/app/views/issues/index.html.erb b/app/views/issues/index.html.erb
new file mode 100644 (file)
index 0000000..2980f5e
--- /dev/null
@@ -0,0 +1,44 @@
+<% content_for :heading do %>
+  <h1><%= t ".title" %></h1>
+<% end %>
+
+<%= form_tag(issues_path, :method => :get) do %>
+<p><%= t ".search_guidance" %></p>
+<%= select :status, nil, Issue.aasm.states.map(&:name).map{|state| [t("issues.states.#{state}"), state]}, { :include_blank => t(".select_status")}, data: { behavior: 'category_dropdown' } %>
+<%= select :issue_type, nil, @issue_types, { :include_blank => t(".select_type")}, data: { behavior: 'category_dropdown' } %>
+<%= text_field_tag :search_by_user, params[:search_by_user], placeholder: t(".reported_user") %>
+<%= select :last_updated_by, nil, @users.all.collect  {|f| [f.display_name, f.id]} << [ t(".not_updated"), "nil"], { :include_blank => t(".select_last_updated_by")}, data: { behavior: 'category_dropdown' } %>
+<%= submit_tag t(".search") %>
+<% end %>
+<br/>
+
+<% if @issues.length == 0 %>
+  <p><%= t ".issues_not_found" %></p>
+<% end %>
+
+<br/>
+
+<table>
+  <thead>
+    <tr>
+      <td><b><%= t ".status" %></b></td>
+      <td><b><%= t ".reports" %></b></td>
+      <td><b><%= t ".reported_item" %></b></td>
+      <td><b><%= t ".reported_user" %></b></td>
+      <td><b><%= t ".last_updated_by" %></b></td>
+      <td><b><%= t ".last_updated_at" %></b></td>
+    </tr>
+  </thead>
+  <tbody>
+    <% @issues.each do |issue| %>
+      <tr>
+        <td><%= t "issues.states.#{issue.status}" %></td>
+        <td><%= link_to t(".reports_count", :count => issue.reports_count), issue %></td>
+        <td><%= link_to reportable_title(issue.reportable), reportable_url(issue.reportable) %></td>
+        <td><%= link_to issue.reported_user.display_name, :controller => :user, :action => :view, :display_name => issue.reported_user.display_name if issue.reported_user %></td>
+        <td><% if issue.user_updated %> <%= issue.user_updated.display_name %> <% else %> - <% end %></td>
+        <td><%= l(issue.updated_at.to_datetime, :format => :friendly) %></td>
+      </tr>
+    <% end %>
+  </tbody>
+</table>
diff --git a/app/views/issues/show.html.erb b/app/views/issues/show.html.erb
new file mode 100644 (file)
index 0000000..732fd2e
--- /dev/null
@@ -0,0 +1,64 @@
+<% content_for :heading do %>
+<h2><%= t ".title", :status => @issue.status.humanize, :issue_id => @issue.id %></h2>
+<p><%= @issue.reportable.model_name.human %> : <%= link_to reportable_title(@issue.reportable), reportable_url(@issue.reportable) %></p>
+<p class="deemphasize">
+  <small>
+    <%= @issue.assigned_role %>
+    | <%= t ".reports", :count => @issue.reports.count %>
+    | <%= t ".report_created_at", :datetime => l(@issue.created_at.to_datetime, :format => :friendly) %>
+    <%= " | " + t(".last_resolved_at", :datetime => l(@issue.resolved_at.to_datetime, :format =>:friendly)) if @issue.resolved_at? %>
+    <%= " | " + t(".last_updated_at", :datetime => l(@issue.updated_at.to_datetime, :format => :friendly), :displayname => @issue.user_updated.display_name ) if @issue.user_updated %>
+  </small>
+</p>
+<p>
+  <%= link_to t('issues.resolve'), resolve_issue_url(@issue), :method => :post if @issue.may_resolve? %>
+  <% if @issue.may_ignore? %>
+  | <%= link_to t('issues.ignore'), ignore_issue_url(@issue), :method => :post %>
+  <% end %>
+</p>
+<p><%= link_to t('issues.reopen'), reopen_issue_url(@issue), :method => :post if @issue.may_reopen? %></p>
+<% end %>
+
+<div class="report-related-block">
+
+  <div class="report-block">
+    <h3><%= t ".reports_of_this_issue" %></h3>
+
+    <% if @read_reports.present? %>
+    <div class="read-reports">
+      <h4><%= t ".read_reports" %></h4>
+      <%= render 'reports', reports: @read_reports %>
+    </div>
+    <% end %>
+
+    <% if @unread_reports.any? %>
+    <div class="unread-reports">
+      <h4><%= t ".new_reports" %></h4>
+      <%= render 'reports', reports: @unread_reports %>
+    </div>
+    <% end %>
+    <br/>
+  </div>
+
+  <% if @issue.reported_user %>
+    <div class="related-block">
+      <h3><%= t ".other_issues_against_this_user" %></h3>
+      <div class="unread-reports">
+        <% if @related_issues.count > 1 %>
+          <% @related_issues.each do |issue| %>
+            <% if issue.id != @issue.id %>
+              <%= link_to reportable_title(issue.reportable), issue %> <br/>
+            <% end %>
+          <% end %>
+        <% else %>
+          <p><%= t ".no_other_issues" %></p>
+        <% end %>
+      </div>
+    </div>
+  <% end %>
+</div>
+
+<h3><%= t ".comments_on_this_issue" %></h3>
+<div class="unread-reports">
+  <%= render 'comments', comments: @comments %>
+</div>
index a5ab460ce67924f3d294639d179238535decf26d..d8e5443b1384406b876739000453b0282a8f4606 100644 (file)
@@ -38,6 +38,9 @@
   </nav>
   <nav class='secondary'>
     <ul>
+      <% if current_user and ( current_user.administrator? or current_user.moderator? ) %>
+        <li class="compact-hide <%= current_page_class(issues_path) %>"><%= link_to t('layouts.issues'), issues_path %></li>
+      <% end %>
       <li class="compact-hide <%= current_page_class(traces_path) %>"><%= link_to t('layouts.gps_traces'), traces_path %></li>
       <li class="compact-hide <%= current_page_class(diary_path) %>"><%= link_to t('layouts.user_diaries'), diary_path %></li>
       <li class="compact-hide <%= current_page_class(copyright_path) %>"><%= link_to t('layouts.copyright'), copyright_path %></li>
diff --git a/app/views/reports/new.html.erb b/app/views/reports/new.html.erb
new file mode 100644 (file)
index 0000000..9812c68
--- /dev/null
@@ -0,0 +1,42 @@
+<% content_for :heading do %>
+  <h1><%= t ".title_html", :link => link_to(reportable_title(@report.issue.reportable), reportable_url(@report.issue.reportable)) %></h1>
+<% end %>
+
+<div class="report-disclaimer">
+  <%= t('issues.new.disclaimer.intro') %>
+  <ul>
+    <li> <%= t('issues.new.disclaimer.not_just_mistake') %> </li>
+    <li> <%= t('issues.new.disclaimer.unable_to_fix') %> </li>
+    <li> <%= t('issues.new.disclaimer.resolve_with_user') %> </li>
+  </ul>
+</div>
+
+<%= form_for(@report) do |f| %>
+  <%= f.error_messages %>
+  <fieldset>
+    <%= f.fields_for @report.issue do |issue_form| %>
+      <%= issue_form.hidden_field :reportable_id %>
+      <%= issue_form.hidden_field :reportable_type %>
+    <% end %>
+
+    <div class='form-row'>
+      <p><%= t('issues.new.select') %></p>
+      <ul>
+      <% Report.categories_for(@report.issue.reportable).each do |c| %>
+        <li>
+          <%= radio_button :report, :category, c %>
+          <%= label_tag "report_category_#{c}", t("reports.categories.#{@report.issue.reportable.class.name}.#{c}") %> <br/>
+        </li>
+      <% end %>
+      </ul>
+    </div>
+
+    <div class='form-row'>
+      <%= text_area :report, :details, :cols => 20, :rows => 5, placeholder: t('issues.new.details'), required: true %>
+    </div>
+
+    <div class='buttons'>
+      <%= f.submit %>
+    </div>
+  </fieldset>
+<% end %>
index 137a390ba1fe6437b3280e7fafa1da6f21135eaf..be2e90b8a537f35d8ed0817e03752760df86a20f 100644 (file)
             </li>
           <% end %>
 
+          <% if current_user and @this_user.id != current_user.id %>
+            <li>
+              <%= link_to new_report_url(reportable_id: @this_user.id, reportable_type: @this_user.class.name), :title => t('user.view.report') do %>
+                &nbsp;&#9872;
+              <% end %>
+            </li>
+          <% end %>
         </ul>
 
       <% end %>
index 54ec7a4682e5b3848bd15de0ac14138fee87587a..6b70afea7e9c03950731b8b30cf16aaf6c6fccda 100644 (file)
@@ -207,6 +207,7 @@ en:
       reopened_by: "Reactivated by %{user} <abbr title='%{exact_time}'>%{when} ago</abbr>"
       reopened_by_anonymous: "Reactivated by anonymous <abbr title='%{exact_time}'>%{when} ago</abbr>"
       hidden_by: "Hidden by %{user} <abbr title='%{exact_time}'>%{when} ago</abbr>"
+      report: Report this note?
     query:
       title: "Query Features"
       introduction: "Click on the map to find nearby features."
@@ -297,10 +298,12 @@ en:
       edit_link: Edit this entry
       hide_link: Hide this entry
       confirm: Confirm
+      report: Report this entry?
     diary_comment:
       comment_from: "Comment from %{link_user} on %{comment_created_at}"
       hide_link: Hide this comment
       confirm: Confirm
+      report: Report this comment?
     location:
       location: "Location:"
       view: "View"
@@ -959,6 +962,106 @@ en:
     results:
       no_results: "No results found"
       more_results: "More results"
+  issues:
+    report: Report
+    resolve: Resolve
+    ignore: Ignore
+    reopen: Reopen
+    index:
+      title: Issues
+      select_status: Select Status
+      select_type: Select Type
+      select_last_updated_by: Select Last Updated By
+      reported_user: Reported User
+      not_updated: Not Updated
+      search: Search
+      search_guidance: "Search Issues:"
+      user_not_found: User does not exist
+      issues_not_found: No such issues found
+      status: Status
+      reports: Reports
+      last_updated_at: Last Updated At
+      last_updated_by: Last Updated By
+      link_to_reports: View Reports
+      reported_user: Reported User
+      reports_count:
+        one: "1 Report"
+        other: "%{count} Reports"
+      reported_item: Reported Item
+    create:
+      successful_report: Your report has been registered sucessfully
+      provide_details: Please provide the required details
+    update:
+      new_report: Your report been registered sucessfully
+      successful_update: Your report has been updated successfully
+      provide_details: Please provide the required details
+    new:
+      details: Please provide some more details about the problem (required).
+      select: "Select a reason for your report:"
+      disclaimer:
+        intro: "Before sending your report to the site moderators, please ensure that:"
+        not_just_mistake: You are certain that the problem is not just a mistake
+        unable_to_fix: You are unable to fix the problem yourself or with the help of your fellow community members
+        resolve_with_user: You have already tried to resolve the problem with the user concerned
+    show:
+      title: "%{status} Issue #%{issue_id}"
+      reports:
+        zero: No reports
+        one: 1 report
+        other: "%{count} reports"
+      report_created_at: "First reported at %{datetime}"
+      last_resolved_at: "Last resolved at %{datetime}"
+      last_updated_at: "Last updated at %{datetime} by %{displayname}"
+      reports_of_this_issue: Reports of this Issue
+      read_reports: Read Reports
+      new_reports: New Reports
+      other_issues_against_this_user: Other issues against this user
+      no_other_issues: No other issues against this user.
+      comments_on_this_issue: Comments on this issue
+      comments:
+        reassign_param: Reassign Issue?
+    comment:
+      provide_details: Please provide the required details
+      comment_created: Your comment was successfully created
+    comments:
+      created_at: "On %{datetime}"
+    reports:
+      category: "Category: %{category}"
+      updated_at: "On %{datetime}"
+      reported_by_html: "Reported by <a href=\"%{user_url}\">%{user_name}</a>"
+    resolved: Issue status has been set to 'Resolved'
+    ignored: Issue status has been set to 'Ignored'
+    reopened: Issue status has been set to 'Open'
+    states:
+      ignored: Ignored
+      open: Open
+      resolved: Resolved
+  reports:
+    new:
+      title_html: "Report %{link}"
+    categories:
+      DiaryEntry:
+        spam: This Diary Entry is/contains spam
+        offensive: This Diary Entry is obscene/offensive
+        threat: This Diary Entry contains a threat
+        other: Other
+      DiaryComment:
+        spam: This Diary Comment is/contains spam
+        offensive: This Diary Comment is obscene/offensive
+        threat: This Diary Comment contains a threat
+        other: Other
+      User:
+        spam: This User profile is/contains spam
+        offensive: This User profile is obscene/offensive
+        threat: This User profile contains a threat
+        vandal: This User is a vandal
+        other: Other
+      Note:
+        spam: This note is spam
+        vandalism: This note is vandalism
+        personal: This note contains personal data
+        abusive: This note is abusive
+        other: Other
   layouts:
     project_name:
       # in <title>
@@ -977,6 +1080,7 @@ en:
     edit: Edit
     history: History
     export: Export
+    issues: Issues
     data: Data
     export_data: Export Data
     gps_traces: GPS Traces
@@ -1169,8 +1273,8 @@ en:
       paragraph_1_html: |
         OpenStreetMap has few formal rules but we expect all participants to collaborate
         with, and communicate with, the community. If you are considering
-        any activities other than editing by hand, please read and follow the guidelines on 
-        <a href='https://wiki.openstreetmap.org/wiki/Import/Guidelines'>Imports</a> and 
+        any activities other than editing by hand, please read and follow the guidelines on
+        <a href='https://wiki.openstreetmap.org/wiki/Import/Guidelines'>Imports</a> and
         <a href='https://wiki.openstreetmap.org/wiki/Automated_Edits_code_of_conduct'>Automated Edits</a>.
     questions:
       title: Any questions?
@@ -1196,7 +1300,7 @@ en:
         title: Join the community
         explanation_html: |
           If you have noticed a problem with our map data, for example a road is missing or your address, the best way to
-          proceed is to join the OpenStreetMap community and add or repair the data yourself. 
+          proceed is to join the OpenStreetMap community and add or repair the data yourself.
       add_a_note:
         instructions_html: |
           Just click <a class='icon note'></a> or the same icon on the map display.
@@ -1206,8 +1310,8 @@ en:
       title: Other concerns
       explanation_html: |
         If you have concerns about how our data is being used or about the contents please consult our
-        <a href='/copyright'>copyright page</a> for more legal information, or contact the appropriate 
-        <a href='https://wiki.osmfoundation.org/wiki/Working_Groups'>OSMF working group</a>.  
+        <a href='/copyright'>copyright page</a> for more legal information, or contact the appropriate
+        <a href='https://wiki.osmfoundation.org/wiki/Working_Groups'>OSMF working group</a>.
   help_page:
     title: Getting Help
     introduction: |
@@ -1277,14 +1381,14 @@ en:
       License page</a> for details.
     legal_title: Legal
     legal_html: |
-      This site and many other related services are formally operated by the  
-      <a href='https://osmfoundation.org/'>OpenStreetMap Foundation</a> (OSMF) 
-      on behalf of the community. Use of all OSMF operated services is subject 
+      This site and many other related services are formally operated by the
+      <a href='https://osmfoundation.org/'>OpenStreetMap Foundation</a> (OSMF)
+      on behalf of the community. Use of all OSMF operated services is subject
       to our <a href="https://wiki.openstreetmap.org/wiki/Acceptable_Use_Policy">
       Acceptable Use Policies</a> and our <a href="https://wiki.osmfoundation.org/wiki/Privacy_Policy">Privacy Policy</a>
-      <br> 
-      Please <a href='https://osmfoundation.org/Contact'>contact the OSMF</a> 
-      if you have licensing, copyright or other legal questions.
+      <br>
+      Please <a href='https://osmfoundation.org/Contact'>contact the OSMF</a>
+      if you have licensing, copyright or other legal questions and issues.
       <br>
       OpenStreetMap, the magnifying glass logo and State of the Map are <a href="https://wiki.osmfoundation.org/wiki/Trademark_Policy">registered trademarks of the OSMF</a>.
     partners_title: Partners
@@ -1682,6 +1786,8 @@ en:
   application:
     require_cookies:
       cookies_needed: "You appear to have cookies disabled - please enable cookies in your browser before continuing."
+    require_admin:
+      not_an_admin: You need to be an admin to perform that action.
     require_moderator:
       not_a_moderator: "You need to be a moderator to perform that action."
     setup_user_auth:
@@ -1953,6 +2059,7 @@ en:
       friends_diaries: "friends' diary entries"
       nearby_changesets: "nearby user changesets"
       nearby_diaries: "nearby user diary entries"
+      report: "Report this user?"
     popup:
       your location: "Your location"
       nearby mapper: "Nearby mapper"
index 66eda1916504026e4c3d76e64ac57b6bee0563a9..8160c878c6304172a4df5c4ac39312ac66b91788 100644 (file)
@@ -223,7 +223,7 @@ OpenStreetMap::Application.routes.draw do
   match "/user/:display_name/diary" => "diary_entry#list", :via => :get
   match "/diary/:language" => "diary_entry#list", :via => :get
   match "/diary" => "diary_entry#list", :via => :get
-  match "/user/:display_name/diary/:id" => "diary_entry#view", :via => :get, :id => /\d+/
+  match "/user/:display_name/diary/:id" => "diary_entry#view", :via => :get, :id => /\d+/, :as => :diary_entry
   match "/user/:display_name/diary/:id/newcomment" => "diary_entry#comment", :via => :post, :id => /\d+/
   match "/user/:display_name/diary/:id/edit" => "diary_entry#edit", :via => [:get, :post], :id => /\d+/
   match "/user/:display_name/diary/:id/hide" => "diary_entry#hide", :via => :post, :id => /\d+/, :as => :hide_diary_entry
@@ -288,6 +288,19 @@ OpenStreetMap::Application.routes.draw do
   resources :user_blocks
   match "/blocks/:id/revoke" => "user_blocks#revoke", :via => [:get, :post], :as => "revoke_user_block"
 
+  # issues and reports
+  resources :issues do
+    resources :comments, :controller => :issue_comments
+    member do
+      post "resolve"
+      post "assign"
+      post "ignore"
+      post "reopen"
+    end
+  end
+
+  resources :reports
+
   # redactions
   resources :redactions
 end
diff --git a/db/migrate/20160822153055_create_issues_and_reports.rb b/db/migrate/20160822153055_create_issues_and_reports.rb
new file mode 100644 (file)
index 0000000..ce2374c
--- /dev/null
@@ -0,0 +1,50 @@
+class CreateIssuesAndReports < ActiveRecord::Migration[5.0]
+  def change
+    create_table :issues do |t|
+      t.string :reportable_type, :null => false
+      t.integer :reportable_id, :null => false
+      t.integer :reported_user_id
+      t.integer :status
+      t.column :assigned_role, :user_role_enum, :null => false
+      t.datetime :resolved_at
+      t.integer :resolved_by
+      t.integer :updated_by
+      t.integer :reports_count, :default => 0
+      t.timestamps :null => false
+    end
+
+    add_foreign_key :issues, :users, :column => :reported_user_id, :name => "issues_reported_user_id_fkey", :on_delete => :cascade
+    add_foreign_key :issues, :users, :column => :updated_by, :name => "issues_updated_by_fkey", :on_delete => :cascade
+
+    add_index :issues, :reported_user_id
+    add_index :issues, [:reportable_id, :reportable_type]
+    add_index :issues, :updated_by
+
+    create_table :reports do |t|
+      t.integer :issue_id
+      t.integer :user_id
+      t.text :details, :null => false
+      t.string :category, :null => false
+      t.timestamps :null => false
+    end
+
+    add_foreign_key :reports, :issues, :name => "reports_issue_id_fkey", :on_delete => :cascade
+    add_foreign_key :reports, :users, :column => :user_id, :name => "reports_user_id_fkey", :on_delete => :cascade
+
+    add_index :reports, :user_id
+    add_index :reports, :issue_id
+
+    create_table :issue_comments do |t|
+      t.integer :issue_id, :null => false
+      t.integer :user_id, :null => false
+      t.text :body, :null => false
+      t.timestamps :null => false
+    end
+
+    add_foreign_key :issue_comments, :issues, :name => "issue_comments_issue_id_fkey", :on_delete => :cascade
+    add_foreign_key :issue_comments, :users, :column => :user_id, :name => "issue_comments_user_id", :on_delete => :cascade
+
+    add_index :issue_comments, :user_id
+    add_index :issue_comments, :issue_id
+  end
+end
index 08aafa8d7ccea6d24ceb06927b777d1aee9ee7df..eac2acaaf6a5fcca1c66e9132490f3ab0fe7cf0d 100644 (file)
@@ -685,6 +685,78 @@ CREATE SEQUENCE gpx_files_id_seq
 ALTER SEQUENCE gpx_files_id_seq OWNED BY gpx_files.id;
 
 
+--
+-- Name: issue_comments; Type: TABLE; Schema: public; Owner: -
+--
+
+CREATE TABLE issue_comments (
+    id integer NOT NULL,
+    issue_id integer NOT NULL,
+    user_id integer NOT NULL,
+    body text NOT NULL,
+    created_at timestamp without time zone NOT NULL,
+    updated_at timestamp without time zone NOT NULL
+);
+
+
+--
+-- Name: issue_comments_id_seq; Type: SEQUENCE; Schema: public; Owner: -
+--
+
+CREATE SEQUENCE issue_comments_id_seq
+    START WITH 1
+    INCREMENT BY 1
+    NO MINVALUE
+    NO MAXVALUE
+    CACHE 1;
+
+
+--
+-- Name: issue_comments_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: -
+--
+
+ALTER SEQUENCE issue_comments_id_seq OWNED BY issue_comments.id;
+
+
+--
+-- Name: issues; Type: TABLE; Schema: public; Owner: -
+--
+
+CREATE TABLE issues (
+    id integer NOT NULL,
+    reportable_type character varying NOT NULL,
+    reportable_id integer NOT NULL,
+    reported_user_id integer,
+    status integer,
+    assigned_role user_role_enum NOT NULL,
+    resolved_at timestamp without time zone,
+    resolved_by integer,
+    updated_by integer,
+    reports_count integer DEFAULT 0,
+    created_at timestamp without time zone NOT NULL,
+    updated_at timestamp without time zone NOT NULL
+);
+
+
+--
+-- Name: issues_id_seq; Type: SEQUENCE; Schema: public; Owner: -
+--
+
+CREATE SEQUENCE issues_id_seq
+    START WITH 1
+    INCREMENT BY 1
+    NO MINVALUE
+    NO MAXVALUE
+    CACHE 1;
+
+
+--
+-- Name: issues_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: -
+--
+
+ALTER SEQUENCE issues_id_seq OWNED BY issues.id;
+
+
 --
 -- Name: languages; Type: TABLE; Schema: public; Owner: -
 --
@@ -986,6 +1058,40 @@ CREATE TABLE relations (
 );
 
 
+--
+-- Name: reports; Type: TABLE; Schema: public; Owner: -
+--
+
+CREATE TABLE reports (
+    id integer NOT NULL,
+    issue_id integer,
+    user_id integer,
+    details text NOT NULL,
+    category character varying NOT NULL,
+    created_at timestamp without time zone NOT NULL,
+    updated_at timestamp without time zone NOT NULL
+);
+
+
+--
+-- Name: reports_id_seq; Type: SEQUENCE; Schema: public; Owner: -
+--
+
+CREATE SEQUENCE reports_id_seq
+    START WITH 1
+    INCREMENT BY 1
+    NO MINVALUE
+    NO MAXVALUE
+    CACHE 1;
+
+
+--
+-- Name: reports_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: -
+--
+
+ALTER SEQUENCE reports_id_seq OWNED BY reports.id;
+
+
 --
 -- Name: schema_migrations; Type: TABLE; Schema: public; Owner: -
 --
@@ -1288,6 +1394,20 @@ ALTER TABLE ONLY gpx_file_tags ALTER COLUMN id SET DEFAULT nextval('gpx_file_tag
 ALTER TABLE ONLY gpx_files ALTER COLUMN id SET DEFAULT nextval('gpx_files_id_seq'::regclass);
 
 
+--
+-- Name: id; Type: DEFAULT; Schema: public; Owner: -
+--
+
+ALTER TABLE ONLY issue_comments ALTER COLUMN id SET DEFAULT nextval('issue_comments_id_seq'::regclass);
+
+
+--
+-- Name: id; Type: DEFAULT; Schema: public; Owner: -
+--
+
+ALTER TABLE ONLY issues ALTER COLUMN id SET DEFAULT nextval('issues_id_seq'::regclass);
+
+
 --
 -- Name: id; Type: DEFAULT; Schema: public; Owner: -
 --
@@ -1330,6 +1450,13 @@ ALTER TABLE ONLY oauth_tokens ALTER COLUMN id SET DEFAULT nextval('oauth_tokens_
 ALTER TABLE ONLY redactions ALTER COLUMN id SET DEFAULT nextval('redactions_id_seq'::regclass);
 
 
+--
+-- Name: id; Type: DEFAULT; Schema: public; Owner: -
+--
+
+ALTER TABLE ONLY reports ALTER COLUMN id SET DEFAULT nextval('reports_id_seq'::regclass);
+
+
 --
 -- Name: id; Type: DEFAULT; Schema: public; Owner: -
 --
@@ -1510,6 +1637,22 @@ ALTER TABLE ONLY gpx_files
     ADD CONSTRAINT gpx_files_pkey PRIMARY KEY (id);
 
 
+--
+-- Name: issue_comments_pkey; Type: CONSTRAINT; Schema: public; Owner: -
+--
+
+ALTER TABLE ONLY issue_comments
+    ADD CONSTRAINT issue_comments_pkey PRIMARY KEY (id);
+
+
+--
+-- Name: issues_pkey; Type: CONSTRAINT; Schema: public; Owner: -
+--
+
+ALTER TABLE ONLY issues
+    ADD CONSTRAINT issues_pkey PRIMARY KEY (id);
+
+
 --
 -- Name: languages_pkey; Type: CONSTRAINT; Schema: public; Owner: -
 --
@@ -1606,6 +1749,14 @@ ALTER TABLE ONLY relations
     ADD CONSTRAINT relations_pkey PRIMARY KEY (relation_id, version);
 
 
+--
+-- Name: reports_pkey; Type: CONSTRAINT; Schema: public; Owner: -
+--
+
+ALTER TABLE ONLY reports
+    ADD CONSTRAINT reports_pkey PRIMARY KEY (id);
+
+
 --
 -- Name: user_blocks_pkey; Type: CONSTRAINT; Schema: public; Owner: -
 --
@@ -1880,6 +2031,41 @@ CREATE INDEX index_client_applications_on_user_id ON client_applications USING b
 CREATE INDEX index_diary_entry_subscriptions_on_diary_entry_id ON diary_entry_subscriptions USING btree (diary_entry_id);
 
 
+--
+-- Name: index_issue_comments_on_issue_id; Type: INDEX; Schema: public; Owner: -
+--
+
+CREATE INDEX index_issue_comments_on_issue_id ON issue_comments USING btree (issue_id);
+
+
+--
+-- Name: index_issue_comments_on_user_id; Type: INDEX; Schema: public; Owner: -
+--
+
+CREATE INDEX index_issue_comments_on_user_id ON issue_comments USING btree (user_id);
+
+
+--
+-- Name: index_issues_on_reportable_id_and_reportable_type; Type: INDEX; Schema: public; Owner: -
+--
+
+CREATE INDEX index_issues_on_reportable_id_and_reportable_type ON issues USING btree (reportable_id, reportable_type);
+
+
+--
+-- Name: index_issues_on_reported_user_id; Type: INDEX; Schema: public; Owner: -
+--
+
+CREATE INDEX index_issues_on_reported_user_id ON issues USING btree (reported_user_id);
+
+
+--
+-- Name: index_issues_on_updated_by; Type: INDEX; Schema: public; Owner: -
+--
+
+CREATE INDEX index_issues_on_updated_by ON issues USING btree (updated_by);
+
+
 --
 -- Name: index_note_comments_on_body; Type: INDEX; Schema: public; Owner: -
 --
@@ -1915,6 +2101,20 @@ CREATE UNIQUE INDEX index_oauth_tokens_on_token ON oauth_tokens USING btree (tok
 CREATE INDEX index_oauth_tokens_on_user_id ON oauth_tokens USING btree (user_id);
 
 
+--
+-- Name: index_reports_on_issue_id; Type: INDEX; Schema: public; Owner: -
+--
+
+CREATE INDEX index_reports_on_issue_id ON reports USING btree (issue_id);
+
+
+--
+-- Name: index_reports_on_user_id; Type: INDEX; Schema: public; Owner: -
+--
+
+CREATE INDEX index_reports_on_user_id ON reports USING btree (user_id);
+
+
 --
 -- Name: index_user_blocks_on_user_id; Type: INDEX; Schema: public; Owner: -
 --
@@ -2327,6 +2527,38 @@ ALTER TABLE ONLY gpx_files
     ADD CONSTRAINT gpx_files_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id);
 
 
+--
+-- Name: issue_comments_issue_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: -
+--
+
+ALTER TABLE ONLY issue_comments
+    ADD CONSTRAINT issue_comments_issue_id_fkey FOREIGN KEY (issue_id) REFERENCES issues(id) ON DELETE CASCADE;
+
+
+--
+-- Name: issue_comments_user_id; Type: FK CONSTRAINT; Schema: public; Owner: -
+--
+
+ALTER TABLE ONLY issue_comments
+    ADD CONSTRAINT issue_comments_user_id FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE;
+
+
+--
+-- Name: issues_reported_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: -
+--
+
+ALTER TABLE ONLY issues
+    ADD CONSTRAINT issues_reported_user_id_fkey FOREIGN KEY (reported_user_id) REFERENCES users(id) ON DELETE CASCADE;
+
+
+--
+-- Name: issues_updated_by_fkey; Type: FK CONSTRAINT; Schema: public; Owner: -
+--
+
+ALTER TABLE ONLY issues
+    ADD CONSTRAINT issues_updated_by_fkey FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE CASCADE;
+
+
 --
 -- Name: messages_from_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: -
 --
@@ -2439,6 +2671,22 @@ ALTER TABLE ONLY relations
     ADD CONSTRAINT relations_redaction_id_fkey FOREIGN KEY (redaction_id) REFERENCES redactions(id);
 
 
+--
+-- Name: reports_issue_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: -
+--
+
+ALTER TABLE ONLY reports
+    ADD CONSTRAINT reports_issue_id_fkey FOREIGN KEY (issue_id) REFERENCES issues(id) ON DELETE CASCADE;
+
+
+--
+-- Name: reports_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: -
+--
+
+ALTER TABLE ONLY reports
+    ADD CONSTRAINT reports_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE;
+
+
 --
 -- Name: user_blocks_moderator_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: -
 --
@@ -2582,6 +2830,7 @@ INSERT INTO "schema_migrations" (version) VALUES
 ('20150111192335'),
 ('20150222101847'),
 ('20150818224516'),
+('20160822153055'),
 ('20161002153425'),
 ('20161011010929'),
 ('20170222134109'),
diff --git a/test/controllers/issue_comments_controller_test.rb b/test/controllers/issue_comments_controller_test.rb
new file mode 100644 (file)
index 0000000..00c80f3
--- /dev/null
@@ -0,0 +1,26 @@
+require "test_helper"
+
+class IssueCommentsControllerTest < ActionController::TestCase
+  def test_comment_by_normal_user
+    issue = create(:issue)
+
+    # Login as normal user
+    session[:user] = create(:user).id
+
+    post :create, :params => { :issue_id => issue.id }
+    assert_response :redirect
+    assert_redirected_to root_path
+  end
+
+  def test_comment
+    issue = create(:issue)
+
+    # Login as administrator
+    session[:user] = create(:administrator_user).id
+
+    post :create, :params => { :issue_id => issue.id, :issue_comment => { :body => "test comment" } }
+    assert_response :redirect
+    assert_redirected_to issue
+    assert_equal 1, issue.comments.length
+  end
+end
diff --git a/test/controllers/issues_controller_test.rb b/test/controllers/issues_controller_test.rb
new file mode 100644 (file)
index 0000000..ff99b0c
--- /dev/null
@@ -0,0 +1,69 @@
+require "test_helper"
+
+class IssuesControllerTest < ActionController::TestCase
+  teardown do
+    # cleanup any emails set off by the test
+    ActionMailer::Base.deliveries.clear
+  end
+
+  def test_view_dashboard_without_auth
+    # Access issues_path without login
+    get :index
+    assert_response :redirect
+    assert_redirected_to login_path(:referer => issues_path)
+
+    # Access issues_path as normal user
+    session[:user] = create(:user).id
+    get :index
+    assert_response :redirect
+    assert_redirected_to root_path
+
+    # Access issues_path by admin
+    session[:user] = create(:administrator_user).id
+    get :index
+    assert_response :success
+
+    # Access issues_path by moderator
+    session[:user] = create(:moderator_user).id
+    get :index
+    assert_response :success
+  end
+
+  def test_change_status_by_normal_user
+    target_user = create(:user)
+    issue = create(:issue, :reportable => target_user, :reported_user => target_user)
+
+    # Login as normal user
+    session[:user] = create(:user).id
+
+    assert_equal 1, Issue.count
+
+    get :resolve, :params => { :id => issue.id }
+
+    assert_response :redirect
+    assert_redirected_to root_path
+  end
+
+  def test_change_status_by_admin
+    target_user = create(:user)
+    issue = create(:issue, :reportable => target_user, :reported_user => target_user)
+
+    # Login as administrator
+    session[:user] = create(:administrator_user).id
+
+    # Test 'Resolved'
+    get :resolve, :params => { :id => issue.id }
+    assert_equal true, Issue.find_by(:reportable_id => target_user.id, :reportable_type => "User").resolved?
+    assert_response :redirect
+
+    # Test 'Reopen'
+    get :reopen, :params => { :id => issue.id }
+    assert_equal true, Issue.find_by(:reportable_id => target_user.id, :reportable_type => "User").open?
+    assert_response :redirect
+
+    # Test 'Ignored'
+    get :ignore, :params => { :id => issue.id }
+    assert_equal true, Issue.find_by(:reportable_id => target_user, :reportable_type => "User").ignored?
+    assert_response :redirect
+  end
+end
diff --git a/test/controllers/reports_controller_test.rb b/test/controllers/reports_controller_test.rb
new file mode 100644 (file)
index 0000000..206958a
--- /dev/null
@@ -0,0 +1,131 @@
+require "test_helper"
+
+class ReportsControllerTest < ActionController::TestCase
+  def test_new_report_without_login
+    target_user = create(:user)
+    get :new, :params => { :reportable_id => target_user.id, :reportable_type => "User" }
+    assert_response :redirect
+    assert_redirected_to login_path(:referer => new_report_path(:reportable_id => target_user.id, :reportable_type => "User"))
+  end
+
+  def test_new_report_after_login
+    target_user = create(:user)
+
+    session[:user] = create(:user).id
+
+    assert_equal 0, Issue.count
+
+    # Create an Issue and a report
+    get :new, :params => { :reportable_id => target_user.id, :reportable_type => "User" }
+    assert_response :success
+    assert_difference "Issue.count", 1 do
+      details = "Details of a report"
+      category = "other"
+      post :create,
+           :params => {
+             :report => {
+               :details => details,
+               :category => category,
+               :issue => { :reportable_id => target_user.id, :reportable_type => "User" }
+             }
+           }
+    end
+    assert_equal 1, Issue.count
+    assert_response :redirect
+    assert_redirected_to root_path
+  end
+
+  def test_new_report_with_incomplete_details
+    # Test creation of a new issue and a new report
+    target_user = create(:user)
+
+    # Login
+    session[:user] = create(:user).id
+
+    assert_equal 0, Issue.count
+
+    # Create an Issue and a report
+    get :new, :params => { :reportable_id => target_user.id, :reportable_type => "User" }
+    assert_response :success
+    assert_difference "Issue.count", 1 do
+      details = "Details of a report"
+      category = "other"
+      post :create,
+           :params => {
+             :report => {
+               :details => details,
+               :category => category,
+               :issue => { :reportable_id => target_user.id, :reportable_type => "User" }
+             }
+           }
+    end
+    assert_equal 1, Issue.count
+    assert_response :redirect
+    assert_redirected_to root_path
+
+    get :new, :params => { :reportable_id => target_user.id, :reportable_type => "User" }
+    assert_response :success
+
+    # Report without details
+    assert_no_difference "Issue.count" do
+      category = "other"
+      post :create,
+           :params => {
+             :report => {
+               :category => category,
+               :issue => { :reportable_id => 1, :reportable_type => "User" }
+             }
+           }
+    end
+    assert_response :redirect
+    assert_equal 1, Issue.find_by(:reportable_id => target_user.id, :reportable_type => "User").reports.count
+  end
+
+  def test_new_report_with_complete_details
+    # Test creation of a new issue and a new report
+    target_user = create(:user)
+
+    # Login
+    session[:user] = create(:user).id
+
+    assert_equal 0, Issue.count
+
+    # Create an Issue and a report
+    get :new, :params => { :reportable_id => target_user.id, :reportable_type => "User" }
+    assert_response :success
+    assert_difference "Issue.count", 1 do
+      details = "Details of a report"
+      category = "other"
+      post :create,
+           :params => {
+             :report => {
+               :details => details,
+               :category => category,
+               :issue => { :reportable_id => target_user.id, :reportable_type => "User" }
+             }
+           }
+    end
+    assert_equal 1, Issue.count
+    assert_response :redirect
+    assert_redirected_to root_path
+
+    # Create a report for an existing Issue
+    get :new, :params => { :reportable_id => target_user.id, :reportable_type => "User" }
+    assert_response :success
+    assert_no_difference "Issue.count" do
+      details = "Details of another report under the same issue"
+      category = "other"
+      post :create,
+           :params => {
+             :report => {
+               :details => details,
+               :category => category,
+               :issue => { :reportable_id => target_user.id, :reportable_type => "User" }
+             }
+           }
+    end
+    assert_response :redirect
+    report_count = Issue.find_by(:reportable_id => target_user.id, :reportable_type => "User").reports.count
+    assert_equal 2, report_count
+  end
+end
diff --git a/test/factories/issues.rb b/test/factories/issues.rb
new file mode 100644 (file)
index 0000000..913c47f
--- /dev/null
@@ -0,0 +1,7 @@
+FactoryBot.define do
+  factory :issue do
+    # Default to reporting users
+    association :reportable, :factory => :user
+    association :reported_user, :factory => :user
+  end
+end
diff --git a/test/factories/reports.rb b/test/factories/reports.rb
new file mode 100644 (file)
index 0000000..7936d46
--- /dev/null
@@ -0,0 +1,8 @@
+FactoryBot.define do
+  factory :report do
+    sequence(:details) { |n| "Report details #{n}" }
+    category "other"
+    issue
+    user
+  end
+end
diff --git a/test/models/issue_comment_test.rb b/test/models/issue_comment_test.rb
new file mode 100644 (file)
index 0000000..53ba358
--- /dev/null
@@ -0,0 +1,7 @@
+require "test_helper"
+
+class IssueCommentTest < ActiveSupport::TestCase
+  # test "the truth" do
+  #   assert true
+  # end
+end
diff --git a/test/models/issue_test.rb b/test/models/issue_test.rb
new file mode 100644 (file)
index 0000000..5af3779
--- /dev/null
@@ -0,0 +1,46 @@
+require "test_helper"
+
+class IssueTest < ActiveSupport::TestCase
+  def test_reported_user
+    note = create(:note_comment, :author => create(:user)).note
+    user = create(:user)
+    create(:language, :code => "en")
+    diary_entry = create(:diary_entry)
+    issue = Issue.new
+
+    issue.reportable = user
+    issue.save!
+    assert_equal issue.reported_user, user
+
+    # FIXME: doesn't handle anonymous notes
+    issue.reportable = note
+    issue.save!
+    assert_equal issue.reported_user, note.author
+
+    issue.reportable = diary_entry
+    issue.save!
+    assert_equal issue.reported_user, diary_entry.user
+  end
+
+  def test_default_assigned_role
+    create(:language, :code => "en")
+    diary_entry = create(:diary_entry)
+    note = create(:note_with_comments)
+
+    issue = Issue.new
+    issue.reportable = diary_entry
+    issue.save!
+    assert_equal "administrator", issue.assigned_role
+
+    issue = Issue.new
+    issue.reportable = note
+    issue.save!
+    assert_equal "moderator", issue.assigned_role
+
+    # check the callback doesn't override an explicitly set role
+    issue.assigned_role = "administrator"
+    issue.save!
+    issue.reload
+    assert_equal "administrator", issue.assigned_role
+  end
+end
diff --git a/test/models/report_test.rb b/test/models/report_test.rb
new file mode 100644 (file)
index 0000000..fad7629
--- /dev/null
@@ -0,0 +1,19 @@
+require "test_helper"
+
+class ReportTest < ActiveSupport::TestCase
+  def test_details_required
+    report = create(:report)
+
+    assert report.valid?
+    report.details = ""
+    assert !report.valid?
+  end
+
+  def test_category_required
+    report = create(:report)
+
+    assert report.valid?
+    report.category = ""
+    assert !report.valid?
+  end
+end
diff --git a/test/system/issues_test.rb b/test/system/issues_test.rb
new file mode 100644 (file)
index 0000000..f11b05a
--- /dev/null
@@ -0,0 +1,111 @@
+require "application_system_test_case"
+
+class IssuesTest < ApplicationSystemTestCase
+  include IssuesHelper
+
+  def test_view_issues_normal_user
+    sign_in_as(create(:user))
+
+    visit issues_path
+    assert page.has_content?(I18n.t("application.require_admin.not_an_admin"))
+  end
+
+  def test_view_no_issues
+    sign_in_as(create(:moderator_user))
+
+    visit issues_path
+    assert page.has_content?(I18n.t(".issues.index.issues_not_found"))
+  end
+
+  def test_view_issues
+    sign_in_as(create(:moderator_user))
+    issues = create_list(:issue, 3, :assigned_role => "moderator")
+
+    visit issues_path
+    assert page.has_content?(issues.first.reported_user.display_name)
+  end
+
+  def test_view_issues_with_no_reported_user
+    sign_in_as(create(:moderator_user))
+    anonymous_note = create(:note_with_comments)
+    issue = create(:issue, :reportable => anonymous_note)
+
+    visit issues_path
+    assert page.has_content?(reportable_title(anonymous_note))
+
+    visit issue_path(issue)
+    assert page.has_content?(reportable_title(anonymous_note))
+  end
+
+  def test_search_issues_by_user
+    good_user = create(:user)
+    bad_user = create(:user)
+    create(:issue, :reportable => bad_user, :reported_user => bad_user, :assigned_role => "administrator")
+
+    sign_in_as(create(:administrator_user))
+
+    # No issues against the user
+    visit issues_path
+    fill_in "search_by_user", :with => good_user.display_name
+    click_on "Search"
+    assert page.has_content?(I18n.t(".issues.index.issues_not_found"))
+
+    # User doesn't exist
+    visit issues_path
+    fill_in "search_by_user", :with => "Nonexistant User"
+    click_on "Search"
+    assert page.has_content?(I18n.t(".issues.index.user_not_found"))
+
+    # Find Issue against bad_user
+    visit issues_path
+    fill_in "search_by_user", :with => bad_user.display_name
+    click_on "Search"
+    assert !page.has_content?(I18n.t(".issues.index.issues_not_found"))
+  end
+
+  def test_commenting
+    issue = create(:issue)
+    sign_in_as(create(:moderator_user))
+
+    visit issue_path(issue)
+
+    fill_in :issue_comment_body, :with => "test comment"
+    click_on "Submit"
+    assert page.has_content?(I18n.t(".issues.comment.comment_created"))
+    assert page.has_content?("test comment")
+
+    issue.reload
+    assert_equal issue.comments.first.body, "test comment"
+  end
+
+  def test_reassign_issue
+    issue = create(:issue)
+    assert_equal "administrator", issue.assigned_role
+    sign_in_as(create(:administrator_user))
+
+    visit issue_path(issue)
+
+    fill_in :issue_comment_body, :with => "reassigning to moderators"
+    check :reassign
+    click_on "Submit"
+
+    issue.reload
+    assert_equal "moderator", issue.assigned_role
+  end
+
+  def test_issue_index_with_multiple_roles
+    user1 = create(:user)
+    user2 = create(:user)
+    issue1 = create(:issue, :reportable => user1, :assigned_role => "administrator")
+    issue2 = create(:issue, :reportable => user2, :assigned_role => "moderator")
+
+    user = create(:administrator_user)
+    create(:user_role, :user => user, :role => "moderator")
+    sign_in_as(user)
+
+    visit issues_path
+
+    assert page.has_link?(user1.display_name, :href => issue_path(issue1))
+    assert page.has_link?(user2.display_name, :href => issue_path(issue2))
+  end
+end
diff --git a/test/system/report_anonymous_note_test.rb b/test/system/report_anonymous_note_test.rb
new file mode 100644 (file)
index 0000000..a5a15d3
--- /dev/null
@@ -0,0 +1,30 @@
+require "application_system_test_case"
+
+class ReportAnonymousNoteTest < ApplicationSystemTestCase
+  def test_no_flag_when_not_logged_in
+    note = create(:note_with_comments)
+    visit browse_note_path(note)
+    assert page.has_content?(note.comments.first.body)
+
+    assert !page.has_content?("\u2690")
+  end
+
+  def test_can_report_anonymous_notes
+    note = create(:note_with_comments)
+    sign_in_as(create(:user))
+    visit browse_note_path(note)
+
+    click_on "\u2690"
+    assert page.has_content? "Report"
+    assert page.has_content? I18n.t("issues.new.disclaimer.intro")
+
+    choose I18n.t("reports.categories.Note.spam")
+    fill_in "report_details", :with => "This is spam"
+    click_on "Create Report"
+
+    assert page.has_content? "Your report has been registered sucessfully"
+
+    assert_equal 1, Issue.count
+    assert Issue.last.reportable == note
+  end
+end
diff --git a/test/system/report_diary_entry_test.rb b/test/system/report_diary_entry_test.rb
new file mode 100644 (file)
index 0000000..988bfbe
--- /dev/null
@@ -0,0 +1,52 @@
+require "application_system_test_case"
+
+class ReportDiaryEntryTest < ApplicationSystemTestCase
+  def setup
+    create(:language, :code => "en")
+    @diary_entry = create(:diary_entry)
+  end
+
+  def test_no_flag_when_not_logged_in
+    visit diary_entry_path(@diary_entry.user.display_name, @diary_entry)
+    assert page.has_content?(@diary_entry.title)
+
+    assert !page.has_content?("\u2690")
+  end
+
+  def test_it_works
+    sign_in_as(create(:user))
+    visit diary_entry_path(@diary_entry.user.display_name, @diary_entry)
+    assert page.has_content? @diary_entry.title
+
+    click_on "\u2690"
+    assert page.has_content? "Report"
+    assert page.has_content? I18n.t("issues.new.disclaimer.intro")
+
+    choose I18n.t("reports.categories.DiaryEntry.spam")
+    fill_in "report_details", :with => "This is advertising"
+    click_on "Create Report"
+
+    assert page.has_content? "Your report has been registered sucessfully"
+  end
+
+  def test_it_reopens_issue
+    issue = create(:issue, :reportable => @diary_entry)
+    issue.resolve!
+
+    sign_in_as(create(:user))
+    visit diary_entry_path(@diary_entry.user.display_name, @diary_entry)
+    assert page.has_content? @diary_entry.title
+
+    click_on "\u2690"
+    assert page.has_content? "Report"
+    assert page.has_content? I18n.t("issues.new.disclaimer.intro")
+
+    choose I18n.t("reports.categories.DiaryEntry.spam")
+    fill_in "report_details", :with => "This is advertising"
+    click_on "Create Report"
+
+    issue.reload
+    assert !issue.resolved?
+    assert issue.open?
+  end
+end
index c9ec46dcf41cf1b78c9cab334b8027dde167aa0e..95d48bd5c7f2ed0485173106b043378d61f98abe 100644 (file)
@@ -150,5 +150,13 @@ module ActiveSupport
         end
       end
     end
+
+    def sign_in_as(user)
+      stub_hostip_requests
+      visit login_path
+      fill_in "username", :with => user.email
+      fill_in "password", :with => "test"
+      click_on "Login", :match => :first
+    end
   end
 end