[WIP] Allow mods to remove tags and send user notification (#1252)

* Initial tag adjustments

* Finalize tag adjustments and tag adjustments controller

* Fix schema.rb

* Finalize tag removal logic

* Adjust edge cases/tests for tag adjustment moderation

* Adjust tag adjustments spec
This commit is contained in:
Ben Halpern 2018-12-04 16:45:32 -05:00 committed by GitHub
parent aefec87214
commit 6b96201265
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 429 additions and 3 deletions

View file

@ -0,0 +1,14 @@
class TagAdjustmentsController < ApplicationController
def create
authorize(User, :moderation_routes?)
TagAdjustmentCreationService.new(current_user, {
adjustment_type: "removal",
status: "committed",
tag_name: params[:tag_adjustment][:tag_name],
article_id: params[:tag_adjustment][:article_id],
reason_for_adjustment: params[:tag_adjustment][:reason_for_adjustment]
}).create
@article = Article.find(params[:tag_adjustment][:article_id])
redirect_to "#{@article.path}/mod"
end
end

View file

@ -403,6 +403,9 @@ class Article < ApplicationRecord
ActsAsTaggableOn::Taggable::Cache.included(Article)
self.tag_list = []
tag_list.add(front_matter["tags"], parser: ActsAsTaggableOn::TagParser)
TagAdjustment.where(article_id: id, adjustment_type: "removal", status: "committed").pluck(:tag_name).each do |name|
tag_list.remove(name, parser: ActsAsTaggableOn::TagParser)
end
end
self.published = front_matter["published"] if ["true", "false"].include?(front_matter["published"].to_s)
self.published_at = parsed_date(front_matter["date"]) if published
@ -425,6 +428,11 @@ class Article < ApplicationRecord
end
def validate_tag
# remove adjusted tags
TagAdjustment.where(article_id: id, adjustment_type: "removal", status: "committed").pluck(:tag_name).each do |name|
tag_list.remove(name, parser: ActsAsTaggableOn::TagParser)
self.tag_list = tag_list
end
return errors.add(:tag_list, "exceed the maximum of 4 tags") if tag_list.length > 4
tag_list.each do |tag|
if tag.length > 20

View file

@ -187,6 +187,26 @@ class Notification < ApplicationRecord
end
handle_asynchronously :send_moderation_notification
def send_tag_adjustment_notification(notifiable)
article = notifiable.article
json_data = {
article: {title: article.title, path: article.path},
adjustment_type: notifiable.adjustment_type,
status: notifiable.status,
reason_for_adjustment: notifiable.reason_for_adjustment,
tag_name: notifiable.tag_name
}
Notification.create(
user_id: article.user_id,
notifiable_id: notifiable.id,
notifiable_type: notifiable.class.name,
json_data: json_data,
)
article.user.update_column(:last_moderation_notification, Time.current)
end
handle_asynchronously :send_tag_adjustment_notification
def remove_all(notifiable_hash)
Notification.where(
notifiable_id: notifiable_hash[:id],

View file

@ -0,0 +1,23 @@
class TagAdjustment < ApplicationRecord
validates :user_id, presence: true
validates :article_id, presence: true
validates :tag_id, presence: true
validates :tag_name, presence: true, uniqueness: { scope: :article_id }
validates :reason_for_adjustment, presence: true
validates :adjustment_type, inclusion: { in: %w(removal addition) }, presence: true
validates :status, inclusion: { in: %w(committed pending committed_and_resolvable resolved) }, presence: true
validate :user_permissions
belongs_to :user
belongs_to :tag
belongs_to :article
private
def user_permissions
unless user&.has_role?(:tag_moderator, tag) || user&.has_role?(:admin) || user&.has_role?(:super_admin)
errors.add(:user_id, "does not have privilege to adjust these tags")
end
end
end

View file

@ -0,0 +1,27 @@
class TagAdjustmentCreationService
def initialize(user, tag_adjustment_params)
@user = user
@tag_adjustment_params = tag_adjustment_params
end
def create
@tag_adjustment = TagAdjustment.create!(creation_args)
update_article
Notification.send_tag_adjustment_notification(@tag_adjustment)
@tag_adjustment
end
private
def update_article
article = Article.find(creation_args[:article_id])
article.update!(tag_list: article.tag_list.remove(@tag_adjustment.tag_name)) if @tag_adjustment.adjustment_type == "removal"
end
def creation_args
args = @tag_adjustment_params
args[:user_id] = @user.id
args[:tag_id] = Tag.find_by_name(args[:tag_name])&.id
args
end
end

View file

@ -0,0 +1,13 @@
class TagAdjustmentUpdateService
def initialize(tag_adjustment, tag_adjustment_params)
@tag_adjustment = tag_adjustment
@tag_adjustment_params = tag_adjustment_params
end
def update
@tag_adjustment.update(@tag_adjustment_params)
# Overall incomplete
# We don't yet need this but will.
# Not supporting update of notification functionality yet
end
end

View file

@ -20,6 +20,41 @@
.reaction-button.reacted .reacted-emoji {
display: block;
}
.tag-mod-form {
border-top: 2px solid #dddddd;
margin: 40px auto;
}
.tag-mod-form ul {
text-align: left;
font-size: 15px;
}
.tag-mod-form form{
width: 500px;
margin: auto;
max-width: 90%;
}
.tag-mod-form form input{
width: 100%;
padding: 5px;
font-size: 20px;
margin-bottom: 5px;
border: 1px solid #888;
}
.tag-mod-form form input[type="submit"]{
background: #0045ff;
color: white;
font-weight: bold;
padding: 10px;
margin: auto;
display: block;
}
.tag-mod-form form textarea{
width: 100%;
padding: 5px;
font-size: 17px;
height: 50px;
border: 1px solid #888;
}
</style>
<div class="container" style="text-align: center">
@ -33,7 +68,6 @@
<%= image_tag "emoji/emoji-one-nausea-face-gray.png" %>
<img class="reacted-emoji" src="<%= asset_path("emoji/emoji-one-nausea-face.png") %>"/>
</button>
<% if current_user.has_role?(:super_admin) && @moderatable.class.name == "Article" %>
<h1> Admin: <a href="<%= @moderatable.path %>/edit">Edit</a> | <a href="/internal/articles/<%= @moderatable.id %>" data-no-instant>Content Moderation</a> | <a href="/admin/articles/<%= @moderatable.id %>" data-no-instant>Article Admin</a></h1>
<% elsif current_user.has_role?(:super_admin) && @moderatable.class.name == "Comment" %>
@ -46,6 +80,36 @@
<br><br>The DEV team will be notified about vomits and take appropriate action, but leaving a level-headed comment addressing the behavior is welcome if you feel up for it.
</p>
<% end %>
<% is_mod = (@moderatable.tag_list.select { |t| current_user.has_role?(:tag_moderator, Tag.find_by_name(t)) }).any? if @moderatable.class.name == "Article" %>
<% if @moderatable.class.name == "Article" && (current_user.has_role?(:super_admin) || is_mod) %>
<div class="tag-mod-form" >
<%= form_for(TagAdjustment.new) do |f| %>
<h2> Remove Innapropriate Tags</h2>
<% unless current_user.has_role?(:super_admin) %>
<ul>
<li>You can only remove tags that you moderate.</li>
<li>Only remove tags which are tagged innapropriately.</li>
<li>Use negative reactions if tag is appropriate but quality is low.</li>
<li>Always be friendly in your tag removal reason.</li>
</ul>
<% end %>
<b>Current live tags:</b> <%= @moderatable.tag_list %>
<br/><br/>
<%= f.hidden_field :article_id, value: @moderatable.id %>
<%= f.text_field :tag_name, placeholder: "Tag Name" %>
<%= f.text_area :reason_for_adjustment, placeholder: "Reason for Removal (Be super kind)" %>
<%= f.submit "Remove Tag" %>
<% adjustments = TagAdjustment.where(article_id: @moderatable.id, adjustment_type: "removal") %>
<% if adjustments.any? %>
<br/><br/>
<b>Removed tags: </b>
<% adjustments.each do |adjustment| %>
<%= adjustment.tag_name %>
<% end %>
<% end %>
<% end %>
</div>
<% end %>
</div>
<script defer>

View file

@ -0,0 +1,22 @@
<div class="small-pic">
<img src="https://res.cloudinary.com/practicaldev/image/fetch/s--LnVw15KE--/c_fill,f_auto,fl_progressive,h_90,q_auto,w_90/https://thepracticaldev.s3.amazonaws.com/uploads/user/profile_image/31047/af153cd6-9994-4a68-83f4-8ddf3e13f0bf.jpg"
alt="">
</div>
<div class="content notification-content comment-content">
<% data = notification.json_data %>
Hey there 👋
<br/><br/>
Moderators removed the <b><a href="/t/<%= data["tag_name"] %>" style="font-weight: 900">#<%= data["tag_name"] %></a></b> tag from your post <a href="<%= data["article"]["path"] %>"><%= data["article"]["title"] %></a>.
<br/><br/>
Certain tags have special submission guidelines, and mods need to adjust tags to best fit the community.
<br/><br/>
<div class="comment-text">
<div style="font-size:0.8em;margin-top: -3px;font-weight: 900;margin-bottom: 5px;">Reason for action</div>
<%= data["reason_for_adjustment"] %>
</div>
<br/>
Check out <a href="/tags">other popular tags</a> which may be more appropriate.
<br><br/>
Thanks for being part of DEV! If you feel like this mod action was a mistake, feel free to contact <a href="mailto:yo@dev.to">yo@dev.to</a>. 🤗
<br/><br/>
</div>

View file

@ -122,6 +122,7 @@ Rails.application.routes.draw do
resources :html_variant_trials, only: [:create]
resources :html_variant_successes, only: [:create]
resources :push_notification_subscriptions, only: [:create]
resources :tag_adjustments, only: [:create]
get "/notifications/:filter" => "notifications#index"
patch "/onboarding_update" => "users#onboarding_update"

View file

@ -0,0 +1,14 @@
class CreateTagAdjustments < ActiveRecord::Migration[5.1]
def change
create_table :tag_adjustments do |t|
t.integer :user_id
t.integer :article_id
t.integer :tag_id
t.string :tag_name
t.string :adjustment_type
t.string :status
t.string :reason_for_adjustment
t.timestamps
end
end
end

View file

@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema.define(version: 20181129222416) do
ActiveRecord::Schema.define(version: 20181130224531) do
# These are extensions that must be enabled in order to support this database
enable_extension "plpgsql"
@ -441,7 +441,7 @@ ActiveRecord::Schema.define(version: 20181129222416) do
t.index ["chat_channel_id"], name: "index_messages_on_chat_channel_id"
t.index ["user_id"], name: "index_messages_on_user_id"
end
create_table "notes", id: :serial, force: :cascade do |t|
t.integer "author_id"
t.text "content"
@ -599,6 +599,18 @@ ActiveRecord::Schema.define(version: 20181129222416) do
t.index ["google_result_path"], name: "index_search_keywords_on_google_result_path"
end
create_table "tag_adjustments", force: :cascade do |t|
t.string "adjustment_type"
t.integer "article_id"
t.datetime "created_at", null: false
t.string "reason_for_adjustment"
t.string "status"
t.integer "tag_id"
t.string "tag_name"
t.datetime "updated_at", null: false
t.integer "user_id"
end
create_table "taggings", id: :serial, force: :cascade do |t|
t.string "context", limit: 128
t.datetime "created_at"

View file

@ -0,0 +1,18 @@
FactoryBot.define do
factory :tag_adjustment do
user_id { 1 }
article_id { 1 }
tag_id { 1 }
tag_name { "NOTHING" }
adjustment_type { "removal" }
reason_for_adjustment { "reason #{rand(10000)}" }
status { "committed" }
end
end
# t.integer :user_id
# t.integer :article_id
# t.integer :tag_id
# t.string :tag_name
# t.string :adjustment_type
# t.string :status
# t.string :reason_for_adjustment

View file

@ -0,0 +1,68 @@
require 'rails_helper'
RSpec.describe TagAdjustment, type: :model do
it { is_expected.to validate_presence_of(:user_id) }
it { is_expected.to validate_presence_of(:article_id) }
it { is_expected.to validate_presence_of(:tag_id) }
it { is_expected.to validate_presence_of(:tag_name) }
it { is_expected.to validate_presence_of(:adjustment_type) }
it { is_expected.to validate_presence_of(:status) }
let(:regular_user) { create(:user)}
let(:mod_user) { create(:user)}
let(:admin_user) { create(:user)}
let(:tag) { create(:tag)}
let(:article) { create(:article)}
before do
mod_user.add_role(:tag_moderator, tag)
admin_user.add_role(:admin)
end
describe "privileges" do
it "allows tag mods to create for their tags" do
tag_adjustment = build(:tag_adjustment, user_id: mod_user.id, article_id: article.id, tag_id: tag.id)
expect(tag_adjustment).to be_valid
end
it "does not allow tag mods to create for other tags" do
another_tag = create(:tag)
tag_adjustment = build(:tag_adjustment, user_id: mod_user.id, article_id: article.id, tag_id: another_tag.id)
expect(tag_adjustment).to be_invalid
end
it "allows admins to create for any tags" do
tag_adjustment = build(:tag_adjustment, user_id: admin_user.id, article_id: article.id, tag_id: tag.id)
expect(tag_adjustment).to be_valid
end
it "does not allow normal users to create for any tags" do
tag_adjustment = build(:tag_adjustment, user_id: regular_user.id, article_id: article.id, tag_id: tag.id)
expect(tag_adjustment).to be_invalid
end
end
describe "allowed attribute states" do
it "allows proper adjustment_types" do
tag_adjustment = build(:tag_adjustment, user_id: mod_user.id, article_id: article.id, tag_id: tag.id, adjustment_type: "removal")
expect(tag_adjustment).to be_valid
end
it "disallows improper adjustment_types" do
tag_adjustment = build(:tag_adjustment, user_id: mod_user.id, article_id: article.id, tag_id: tag.id, adjustment_type: "slushie")
expect(tag_adjustment).to be_invalid
end
it "allows proper status" do
tag_adjustment = build(:tag_adjustment, user_id: mod_user.id, article_id: article.id, tag_id: tag.id, status: "committed")
expect(tag_adjustment).to be_valid
end
it "disallows improper status" do
tag_adjustment = build(:tag_adjustment, user_id: mod_user.id, article_id: article.id, tag_id: tag.id, status: "slushiemonkey")
expect(tag_adjustment).to be_invalid
end
end
end
# t.integer :user_id
# t.integer :article_id
# t.integer :tag_id
# t.string :tag_name
# t.string :adjustment_type
# t.string :status
# t.string :reason_for_adjustment

View file

@ -0,0 +1,63 @@
require "rails_helper"
RSpec.describe "TagAdjustments", type: :request do
let(:user) { create(:user) }
let(:user2) { create(:user) }
let(:tag) { create(:tag) }
# let(:article) { create(:article, ) }
describe "POST /tag_adjustments" do
context "when signed in" do
before do
user.add_role(:tag_moderator, tag)
user.add_role(:trusted)
sign_in user
end
it "removes the tag" do
article = Article.create!(user_id: user2.id, title: "test TEST", body_markdown: "Yo ho h o#{rand(100)}", tag_list: "#{tag.name}, yoyo, bobo", published: true)
post "/tag_adjustments", params: {
tag_adjustment: {
tag_name: tag.name,
article_id: article.id,
reason_for_adjustment: "Test #{rand(100)}"
},
}
expect(article.reload.tag_list.include?(tag.name)).to eq(false)
end
it "keeps the other tags" do
article = Article.create!(user_id: user2.id, title: "test TEST", body_markdown: "Yo ho h o#{rand(100)}", tag_list: "#{tag.name}, yoyo, bobo", published: true)
post "/tag_adjustments", params: {
tag_adjustment: {
tag_name: tag.name,
article_id: article.id,
reason_for_adjustment: "Test #{rand(100)}"
},
article_id: article.id
}
expect(article.reload.tag_list.include?("yoyo")).to eq(true)
end
it "removes the tag in a frontmatter context" do
article = Article.create!(user_id: user2.id, body_markdown: "---\ntitle: Hellohnnnn#{rand(1000)}\npublished: true\ntags: heyheyhey,#{tag.name}\n---\n\nHello")
post "/tag_adjustments", params: {
tag_adjustment: {
tag_name: tag.name,
article_id: article.id,
reason_for_adjustment: "Test #{rand(100)}"
},
}
expect(article.reload.tag_list.include?(tag.name)).to eq(false)
end
it "keeps the other tags in a frontmatter context" do
article = Article.create!(user_id: user2.id, body_markdown: "---\ntitle: Hellohnnnn#{rand(1000)}\npublished: true\ntags: heyheyhey,#{tag.name}\n---\n\nHello")
post "/tag_adjustments", params: {
tag_adjustment: {
tag_name: tag.name,
article_id: article.id,
reason_for_adjustment: "Test #{rand(100)}"
},
}
expect(article.reload.tag_list.include?("heyheyhey")).to eq(true)
end
end
end
end

View file

@ -0,0 +1,29 @@
require "rails_helper"
RSpec.describe TagAdjustmentCreationService do
let(:user) { create(:user) }
let(:article) { create(:article) }
let(:tag) { create(:tag) }
before do
user.add_role(:tag_moderator, tag)
@tag_adjustment = described_class.new(user, {
adjustment_type: "removal",
status: "committed",
tag_name: tag.name,
article_id: article.id,
reason_for_adjustment: "Test"
}).create
end
it "creates tag adjustment" do
expect(@tag_adjustment).to be_valid
expect(@tag_adjustment.tag_id).to eq(tag.id)
expect(@tag_adjustment.status).to eq("committed")
end
it "creates notification" do
expect(Notification.last.user_id).to eq(article.user_id)
expect(Notification.last.json_data["adjustment_type"]).to eq(@tag_adjustment.adjustment_type)
end
end

View file

@ -0,0 +1,30 @@
require "rails_helper"
RSpec.describe TagAdjustmentUpdateService do
let(:user) { create(:user) }
let(:article) { create(:article) }
let(:tag) { create(:tag) }
before do
user.add_role(:tag_moderator, tag)
@tag_adjustment = TagAdjustmentCreationService.new(user, {
adjustment_type: "removal",
status: "committed",
tag_name: tag.name,
article_id: article.id
}).create
end
xit "creates tag adjustment" do
described_class.new(@tag_adjustment, {status: "resolved"}).update
expect(@tag_adjustment).to be_valid
expect(@tag_adjustment.tag_id).to eq(tag.id)
expect(@tag_adjustment.status).to eq("resolved")
end
xit "updates notification" do
described_class.new(@tag_adjustment, {status: "resolved"}).update
expect(Notification.last.user_id).to eq(article.user_id)
expect(Notification.last.json_data["status"]).to eq("resolved")
end
end