Add experience level averaging to articles (#1922)

* Add experience level averaging to articles

* Adjust copy on /mod ratings page
This commit is contained in:
Ben Halpern 2019-02-28 08:17:29 -08:00 committed by GitHub
parent 704b7bce9c
commit 532903c688
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
22 changed files with 355 additions and 13 deletions

View file

@ -33,6 +33,7 @@ function insertNewArticles(user){
var containsOrganizationID = findOne([article.organization_id], user.followed_organization_ids || [])
var intersectedTags = intersect_arrays(user.followed_tag_names, article.cached_tag_list_array)
var followedPoints = 1
var experienceDifference = Math.abs(article['experience_level_rating'] - user.experience_level)
JSON.parse(user.followed_tags).map(function(tag) {
if (intersectedTags.includes(tag.name)) {
followedPoints = followedPoints + tag.points
@ -51,6 +52,7 @@ function insertNewArticles(user){
} else if (rand < 0.6) {
articlePoints = articlePoints + 2
}
articlePoints = articlePoints - (experienceDifference/2);
article['points'] = articlePoints
});
var sortedArticles = articlesJSON.sort(function(a, b) {
@ -70,12 +72,13 @@ function insertTopArticles(user){
var articlesJSON = JSON.parse(el.dataset.articles)
var insertPlace = document.getElementById("article-index-hidden-div");
if (insertPlace) {
articlesJSON.forEach(function(article){
articlesJSON.forEach(function(article){
var articlePoints = 0
var containsUserID = findOne([article.user_id], user.followed_user_ids || [])
var containsOrganizationID = findOne([article.organization_id], user.followed_organization_ids || [])
var intersectedTags = intersect_arrays(user.followed_tag_names, article.cached_tag_list_array)
var followedPoints = 1
var experienceDifference = Math.abs(article['experience_level_rating'] - user.experience_level)
JSON.parse(user.followed_tags).map(function(tag) {
if (intersectedTags.includes(tag.name)) {
followedPoints = followedPoints + tag.points
@ -94,6 +97,7 @@ function insertTopArticles(user){
} else if (rand < 0.6) {
articlePoints = articlePoints + 2
}
articlePoints = articlePoints - (experienceDifference/2);
article['points'] = articlePoints
});
var sortedArticles = articlesJSON.sort(function(a, b) {

View file

@ -46,7 +46,8 @@ class AsyncInfoController < ApplicationController
checked_code_of_conduct: @user.checked_code_of_conduct,
number_of_comments: @user.comments.count,
display_sponsors: @user.display_sponsors,
trusted: @user.trusted
trusted: @user.trusted,
experience_level: @user.experience_level
}
end
end

View file

@ -1,6 +1,20 @@
class ModerationsController < ApplicationController
after_action :verify_authorized
def index
authorize(User, :moderation_routes?)
@articles = Article.where(published: true).
includes(:rating_votes).
where("rating_votes_count < 3").
where("score > -5").
order("featured_number DESC").limit(50)
if params[:tag].present?
@articles = @articles.
cached_tagged_with(params[:tag])
end
@articles = @articles.decorate
end
def article
authorize(User, :moderation_routes?)
@moderatable = Article.find_by_slug(params[:slug])

View file

@ -0,0 +1,24 @@
class RatingVotesController < ApplicationController
after_action :verify_authorized
def create
authorize RatingVote
rating_vote = RatingVote.where(user_id: current_user.id, article_id: rating_vote_params[:article_id]).first || RatingVote.new
rating_vote.user_id = current_user.id
rating_vote.article_id = rating_vote_params[:article_id]
rating_vote.rating = rating_vote_params[:rating].to_f
rating_vote.group = rating_vote_params[:group]
if rating_vote.save
rating_vote.assign_article_rating
redirect_back(fallback_location: "/mod")
else
render json: { result: "Not Upserted Successfully" }
end
end
private
def rating_vote_params
params.require(:rating_vote).permit(policy(RatingVote).permitted_attributes)
end
end

View file

@ -106,7 +106,7 @@ class StoriesController < ApplicationController
def handle_base_index
@home_page = true
@page = (params[:page] || 1).to_i
num_articles = 25
num_articles = 30
@stories = article_finder(num_articles)
add_param_context(:page, :timeframe)
if ["week", "month", "year", "infinity"].include?(params[:timeframe])

View file

@ -17,7 +17,8 @@ class Article < ApplicationRecord
belongs_to :collection, optional: true
has_many :comments, as: :commentable
has_many :buffer_updates
has_many :notifications, as: :notifiable
has_many :notifications, as: :notifiable
has_many :rating_votes
validates :slug, presence: { if: :published? }, format: /\A[0-9a-z-]*\z/,
uniqueness: { scope: :user_id }
@ -76,6 +77,7 @@ class Article < ApplicationRecord
:main_image, :main_image_background_hex_color, :updated_at, :slug,
:video, :user_id, :organization_id, :video_source_url, :video_code,
:video_thumbnail_url, :video_closed_caption_track_url,
:experience_level_rating, :experience_level_rating_distribution,
:published_at, :crossposted_at, :boost_states, :description, :reading_time, :video_duration_in_seconds)
}
@ -137,8 +139,8 @@ class Article < ApplicationRecord
per_environment: true,
enqueue: :trigger_delayed_index do
attributes :title, :path, :class_name, :comments_count, :reading_time,
:tag_list, :positive_reactions_count, :id, :hotness_score, :score, :readable_publish_date,
:flare_tag, :user_id, :organization_id, :cloudinary_video_url, :video_duration_in_minutes
:tag_list, :positive_reactions_count, :id, :hotness_score, :score, :readable_publish_date, :flare_tag, :user_id,
:organization_id, :cloudinary_video_url, :video_duration_in_minutes, :experience_level_rating, :experience_level_rating_distribution
attribute :published_at_int do
published_at.to_i
end

27
app/models/rating_vote.rb Normal file
View file

@ -0,0 +1,27 @@
class RatingVote < ApplicationRecord
belongs_to :article
belongs_to :user
validates_uniqueness_of :user_id, scope: :article_id
validates :group, inclusion: { in: %w(experience_level) }
validates :rating, numericality: { greater_than: 0.0, less_than_or_equal_to: 10.0 }
validate :permissions
counter_culture :article
counter_culture :user
def assign_article_rating
ratings = article.rating_votes.where(group: group).pluck(:rating)
average = ratings.sum / ratings.size
article.update_column(:experience_level_rating, average)
article.update_column(:experience_level_rating_distribution, ratings.sort.max - ratings.sort.min)
article.update_column(:last_experience_level_rating_at, Time.current)
end
private
def permissions
unless user&.trusted
errors.add(:user_id, "is not permitted to take this action.")
end
end
end

View file

@ -35,6 +35,7 @@ class User < ApplicationRecord
has_many :chat_channels, through: :chat_channel_memberships
has_many :push_notification_subscriptions, dependent: :destroy
has_many :feedback_messages
has_many :rating_votes
has_many :html_variants, dependent: :destroy
has_many :mentor_relationships_as_mentee,
class_name: "MentorRelationship", foreign_key: "mentee_id"
@ -65,6 +66,7 @@ class User < ApplicationRecord
exclusion: { in: ReservedWords.all, message: "username is reserved" }
validates :twitter_username, uniqueness: { allow_blank: true }
validates :github_username, uniqueness: { allow_blank: true }
validates :experience_level, numericality: { less_than_or_equal_to: 10 }, allow_blank: true
validates :text_color_hex, format: /\A#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})\z/, allow_blank: true
validates :bg_color_hex, format: /\A#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})\z/, allow_blank: true
validates :website_url, :employer_url, :mastodon_url,

View file

@ -0,0 +1,9 @@
class RatingVotePolicy < ApplicationPolicy
def create?
!user_is_banned?
end
def permitted_attributes
%i[rating group article_id]
end
end

View file

@ -73,6 +73,7 @@ class UserPolicy < ApplicationPolicy
employer_name
employer_url
employment_title
experience_level
facebook_url
feed_admin_publish_permission
feed_mark_canonical

View file

@ -0,0 +1,62 @@
<style>
.level-rating-button {
border-radius: 8px;
font-size: 1.1em;
padding: 3px;
width: 42px;
border: 2px solid black;
font-weight: bold;
}
.level-rating-button.selected {
background: #9bebff;
}
</style>
<br /><br /><br /><br />
<center>
<h1>Mod Convenience Dashboard</h1>
<h2>Add "Experience Level" ratings</h2>
<h5>More mod tools will be added as they are created.</h5>
</center>
<div class="container">
<% @articles.each do |article| %>
<% @rating_vote = article.rating_votes.where(user_id: current_user.id).first %>
<% unless @rating_vote %>
<div style="border-bottom: 2px solid black; margin-bottom: 15px;">
<center>
<h1><%= article.title %></h1>
<% cache("main-article-tags-#{article.cached_tag_list}", expires_in: 30.hours) do %>
<div class="tags">
<% article.cached_tag_list_array.each do |tag| %>
<a class="tag" href="/t/<%= tag %>" style="background-color:<%= tag_colors(tag)[:background] %>;color:<%= tag_colors(tag)[:color] %>">#<%= tag %></a>
<% end %>
</div>
<% end %>
</center>
<div class="body">
<%= HTML_Truncator.truncate(article.processed_html,
200, ellipsis: '<a class="comment-read-more" href="' + article.path + '">... Read Entire Post</a>').html_safe %>
</div>
<center>
<h2><a href="<%= article.path %>/mod">Moderate</a></h2>
<h2>Experience Level Target:</h2>
<%= form_for(RatingVote.new) do |f| %>
<%= f.hidden_field :article_id, value: article.id %>
<%= f.hidden_field :group, value: "experience_level" %>
<button value="1" name="rating_vote[rating]" class="level-rating-button <%= "selected" if @rating_vote&.rating == 1.0 %>">1</button>
<button value="2" name="rating_vote[rating]" class="level-rating-button <%= "selected" if @rating_vote&.rating == 2.0 %>">2</button>
<button value="3" name="rating_vote[rating]" class="level-rating-button <%= "selected" if @rating_vote&.rating == 3.0 %>">3</button>
<button value="4" name="rating_vote[rating]" class="level-rating-button <%= "selected" if @rating_vote&.rating == 4.0 %>">4</button>
<button value="5" name="rating_vote[rating]" class="level-rating-button <%= "selected" if @rating_vote&.rating == 5.0 %>">5</button>
<button value="6" name="rating_vote[rating]" class="level-rating-button <%= "selected" if @rating_vote&.rating == 6.0 %>">6</button>
<button value="7" name="rating_vote[rating]" class="level-rating-button <%= "selected" if @rating_vote&.rating == 7.0 %>">7</button>
<button value="8" name="rating_vote[rating]" class="level-rating-button <%= "selected" if @rating_vote&.rating == 8.0 %>">8</button>
<button value="9" name="rating_vote[rating]" class="level-rating-button <%= "selected" if @rating_vote&.rating == 9.0 %>">9</button>
<button value="10" name="rating_vote[rating]" class="level-rating-button <%= "selected" if @rating_vote&.rating == 10.0 %>">10</button>
<% end %>
<br /><br />
</center>
</div>
<% end %>
<% end %>
</div>

View file

@ -55,6 +55,17 @@
height: 50px;
border: 1px solid #888;
}
.level-rating-button {
border-radius: 8px;
font-size: 1.1em;
padding: 3px;
width: 42px;
border: 2px solid black;
font-weight: bold;
}
.level-rating-button.selected {
background: #9bebff;
}
</style>
<div class="container" style="text-align: center">
@ -74,7 +85,7 @@
<h1> ADMIN: <a href="/admin/comments/<%= @moderatable.id %>" data-no-instant>Comment Admin</a> | <a href="/admin/users/<%= @moderatable.user_id %>" data-no-instant>User Admin</a></h1>
<% else %>
<p style="width: 90%;margin:50px auto;text-align:left">
<b style="font-size:1.3em">All negative reactions are 100% private.</b>
<b style="font-size:1.3em">All negative reactions are private.</b>
<br><br>Use the <b>thumbsdown(👎)</b> to move something "down" for any reason (quality, usefulness, code of conduct grey area).
<br><br>The <b>vomit(🤢)</b> is for code of conduct violations (harassment, being an asshole, spam, etc.).
<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.
@ -110,6 +121,31 @@
<% end %>
</div>
<% end %>
<% if @moderatable.class.name == "Article" %>
<% @rating_vote = RatingVote.where(article_id: @moderatable.id, user_id: current_user.id).first %>
<%= form_for(RatingVote.new) do |f| %>
<h2>Experience Level of Post</h2>
<%= f.hidden_field :article_id, value: @moderatable.id %>
<%= f.hidden_field :group, value: "experience_level" %>
<button value="1" name="rating_vote[rating]" class="level-rating-button <%= "selected" if @rating_vote&.rating == 1.0 %>">1</button>
<button value="2" name="rating_vote[rating]" class="level-rating-button <%= "selected" if @rating_vote&.rating == 2.0 %>">2</button>
<button value="3" name="rating_vote[rating]" class="level-rating-button <%= "selected" if @rating_vote&.rating == 3.0 %>">3</button>
<button value="4" name="rating_vote[rating]" class="level-rating-button <%= "selected" if @rating_vote&.rating == 4.0 %>">4</button>
<button value="5" name="rating_vote[rating]" class="level-rating-button <%= "selected" if @rating_vote&.rating == 5.0 %>">5</button>
<button value="6" name="rating_vote[rating]" class="level-rating-button <%= "selected" if @rating_vote&.rating == 6.0 %>">6</button>
<button value="7" name="rating_vote[rating]" class="level-rating-button <%= "selected" if @rating_vote&.rating == 7.0 %>">7</button>
<button value="8" name="rating_vote[rating]" class="level-rating-button <%= "selected" if @rating_vote&.rating == 8.0 %>">8</button>
<button value="9" name="rating_vote[rating]" class="level-rating-button <%= "selected" if @rating_vote&.rating == 9.0 %>">9</button>
<button value="10" name="rating_vote[rating]" class="level-rating-button <%= "selected" if @rating_vote&.rating == 10.0 %>">10</button>
<% end %>
<p style="width: 90%;margin:50px auto;text-align:left">
<b style="font-size:1.1em">All rating votes are private.</b>
<br><br>Provide a rating between 1 and 10 based on which audience would find this post most valuable.
<br><br>1: for brand new developers, 3: for junior developers, 10: for very advanced developers, etc.
<br><br>This will provide <em>approximate and gentle</em> algorithmic adjustments to help deliver relevant content.
<br><br>It will be used as one of many indicators. No one rating will have an overly dramatic affect.
</p>
<% end %>
</div>
<script defer>

View file

@ -9,8 +9,9 @@
decorate %>
<div id="new-articles-object" data-articles="
<%= @new_stories.to_json(
only: %i[title path id user_id comments_count positive_reactions_count
organization_id reading_time video_thumbnail_url video],
only: %i[title path id user_id comments_count positive_reactions_count organization_id
reading_time video_thumbnail_url video video_duration_in_minutes
experience_level_rating experience_level_rating_distribution],
methods: %i[readable_publish_date cached_tag_list_array flare_tag class_name
cloudinary_video_url video_duration_in_minutes],
include: {
@ -22,7 +23,8 @@
<div id="home-articles-object" data-articles="
<%= @stories.to_json(
only: %i[title path id user_id comments_count positive_reactions_count organization_id
reading_time video_thumbnail_url video video_duration_in_minutes],
reading_time video_thumbnail_url video video_duration_in_minutes
experience_level_rating experience_level_rating_distribution],
methods: %i[readable_publish_date cached_tag_list_array flare_tag class_name
cloudinary_video_url video_duration_in_minutes],
include: {

View file

@ -22,6 +22,19 @@
<%= f.submit "SUBMIT", class: "cta" %>
</div>
<% end %>
<h2>Feed Customization</h2>
<%= form_for(@user) do |f| %>
<div class="sub-field">
<%= f.label :experience_level, "Enter your coding experience level (1-10)" %>
<%= f.text_field :experience_level %>
<sub><em>This will not be displayed on your profile or anywhere publicly. It will help determine what content you are shown along with tags you follow etc.</em></sub>
</div>
<div class="field">
<label></label>
<%= f.hidden_field :tab, value: @tab %>
<%= f.submit "SUBMIT", class: "cta" %>
</div>
<% end %>
<h2>Languages</h2>
<h3>Select which languages you'd prefer to see in your feed <span style="color:#e05252">(beta)</span></h3>
<h4 style="font-weight:400">

View file

@ -130,6 +130,7 @@ Rails.application.routes.draw do
resources :html_variant_successes, only: [:create]
resources :push_notification_subscriptions, only: [:create]
resources :tag_adjustments, only: [:create]
resources :rating_votes, only: [:create]
get "/notifications/:filter" => "notifications#index"
get "/notifications/:filter/:org_id" => "notifications#index"
@ -236,6 +237,7 @@ Rails.application.routes.draw do
get "/memberships", to: redirect("/membership")
get "/shop", to: redirect("https://shop.dev.to/")
get "/tag-moderation" => "pages#tag_moderation"
get "/mod" => "moderations#index"
post "/fallback_activity_recorder" => "ga_events#create"

View file

@ -0,0 +1,9 @@
class AddExperienceLevelToPosts < ActiveRecord::Migration[5.1]
def change
add_column :articles, :experience_level_rating, :float, default: 5.0
add_column :articles, :experience_level_rating_distribution, :float, default: 5.0
add_column :articles, :last_experience_level_rating_at, :datetime
add_column :articles, :rating_votes_count, :integer, null: false, default: 0
add_column :users, :rating_votes_count, :integer, null: false, default: 0
end
end

View file

@ -0,0 +1,13 @@
class CreateRatingVotes < ActiveRecord::Migration[5.1]
def change
create_table :rating_votes do |t|
t.bigint :user_id
t.bigint :article_id
t.string :group
t.float :rating
t.timestamps
end
add_index :rating_votes, :user_id
add_index :rating_votes, :article_id
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: 20190216185753) do
ActiveRecord::Schema.define(version: 20190227163803) do
# These are extensions that must be enabled in order to support this database
enable_extension "plpgsql"
@ -69,6 +69,8 @@ ActiveRecord::Schema.define(version: 20190216185753) do
t.string "description"
t.datetime "edited_at"
t.boolean "email_digest_eligible", default: true
t.float "experience_level_rating", default: 5.0
t.float "experience_level_rating_distribution", default: 5.0
t.datetime "facebook_last_buffered"
t.boolean "featured", default: false
t.float "featured_clickthrough_rate", default: 0.0
@ -81,6 +83,7 @@ ActiveRecord::Schema.define(version: 20190216185753) do
t.string "language"
t.datetime "last_buffered"
t.datetime "last_comment_at", default: "2017-01-01 05:00:00"
t.datetime "last_experience_level_rating_at"
t.datetime "last_invoiced_at"
t.decimal "lat", precision: 10, scale: 6
t.boolean "live_now", default: false
@ -100,6 +103,7 @@ ActiveRecord::Schema.define(version: 20190216185753) do
t.boolean "published", default: false
t.datetime "published_at"
t.boolean "published_from_feed", default: false
t.integer "rating_votes_count", default: 0, null: false
t.integer "reactions_count", default: 0, null: false
t.integer "reading_time", default: 0
t.boolean "receive_notifications", default: true
@ -573,6 +577,17 @@ ActiveRecord::Schema.define(version: 20190216185753) do
t.index ["user_id"], name: "index_push_notification_subscriptions_on_user_id"
end
create_table "rating_votes", force: :cascade do |t|
t.bigint "article_id"
t.datetime "created_at", null: false
t.string "group"
t.float "rating"
t.datetime "updated_at", null: false
t.bigint "user_id"
t.index ["article_id"], name: "index_rating_votes_on_article_id"
t.index ["user_id"], name: "index_rating_votes_on_user_id"
end
create_table "reactions", id: :serial, force: :cascade do |t|
t.string "category"
t.datetime "created_at", null: false
@ -797,6 +812,7 @@ ActiveRecord::Schema.define(version: 20190216185753) do
t.datetime "personal_data_updated_at"
t.string "profile_image"
t.datetime "profile_updated_at", default: "2017-01-01 05:00:00"
t.integer "rating_votes_count", default: 0, null: false
t.integer "reactions_count", default: 0, null: false
t.datetime "remember_created_at"
t.string "remember_token"

View file

@ -0,0 +1,6 @@
FactoryBot.define do
factory :rating_vote do
group { "experience_level" }
rating { rand(1.0..8.0) }
end
end

View file

@ -0,0 +1,56 @@
require "rails_helper"
RSpec.describe RatingVote, type: :model do
let(:user) { create(:user, :trusted) }
let(:user2) { create(:user, :trusted) }
let(:user3) { create(:user, :trusted) }
let(:article) { create(:article, user_id: user.id) }
describe "validations" do
it { is_expected.to validate_numericality_of(:rating).is_greater_than(0.0).is_less_than_or_equal_to(10.0) }
it { is_expected.to validate_inclusion_of(:group).in_array(%w(experience_level)) }
end
describe "uniqueness" do
it "does allow a user to create one rating for one article" do
rating = build(:rating_vote, article_id: article.id, user_id: user.id)
expect(rating).to be_valid
end
it "does not allow a user to create multiple ratings for one article" do
create(:rating_vote, article_id: article.id, user_id: user.id)
rating = build(:rating_vote, article_id: article.id, user_id: user.id)
expect(rating).not_to be_valid
end
end
describe "modifies article rating score" do
it "assigns article rating" do
rating = create(:rating_vote, article_id: article.id, user_id: user.id, rating: 2.0)
create(:rating_vote, article_id: article.id, user_id: user2.id, rating: 3.0)
rating.assign_article_rating
expect(article.reload.experience_level_rating).to eq(2.5)
expect(article.reload.experience_level_rating_distribution).to eq(1.0)
end
it "assigns article rating with larger distribution" do
rating = create(:rating_vote, article_id: article.id, user_id: user.id, rating: 1.0)
create(:rating_vote, article_id: article.id, user_id: user2.id, rating: 7.0)
rating.assign_article_rating
expect(article.reload.experience_level_rating).to eq(4.0)
expect(article.reload.experience_level_rating_distribution).to eq(6.0)
end
end
describe "permissions" do
it "allows trusted users to make rating" do
rating = build(:rating_vote, article_id: article.id, user_id: user.id)
expect(rating).to be_valid
end
it "does not allow non-trusted users to make rating" do
nontrusted_user = create(:user)
rating = build(:rating_vote, article_id: article.id, user_id: nontrusted_user.id)
expect(rating).not_to be_valid
end
end
end

View file

@ -37,14 +37,24 @@ RSpec.describe "Moderations", type: :request do
context "when user is trusted" do
before { sign_in user }
it "grant acess to comment moderation" do
it "grant access to comment moderation" do
get comment.path + "/mod"
expect(response).to have_http_status(200)
end
it "grant acess to article moderation" do
it "grant access to article moderation" do
get article.path + "/mod"
expect(response).to have_http_status(200)
end
it "grants access to /mod index" do
get "/mod"
expect(response).to have_http_status(200)
end
it "grants access to /mod index with articles" do
create(:article, published: true)
get "/mod"
expect(response.body).to include("Experience Level Target")
end
end
end

View file

@ -0,0 +1,33 @@
# http://localhost:3000/rating_votes
require "rails_helper"
RSpec.describe "RatingVotes", type: :request do
let(:user) { create(:user, :trusted) }
let(:article) { create(:article) }
before do
sign_in user
end
describe "POST /rating_votes" do
it "creates a new rating vote" do
post "/rating_votes", params: {
rating_vote: {
article_id: article.id, group: "experience_level", rating: 3.0
}
}
expect(RatingVote.last.rating).to eq(3.0)
expect(RatingVote.last.user_id).to eq(user.id)
end
it "does not create new rating vote for non-trusted user" do
user.remove_role(:trusted)
post "/rating_votes", params: {
rating_vote: {
article_id: article.id, group: "experience_level", rating: 3.0
}
}
expect(RatingVote.all.size).to eq(0)
end
end
end