Update User Dashboard With Mentorship Admin Features (#440)

* create mentor relationship table and begin dashboard

* add missing files from previous commit

* add mentorship relationship status

* remove uniqueness validation

* add multiple notes

* ban from mentorship deactives all relationships

* add untracked files

* fix logic issue and add specs

* add model specs

* add request specs

* add request specs and fix linting

* fix internal users spec

* remove phantom columns

* remove bizzaro character

* move all attr_accessor to one line
This commit is contained in:
Jess Lee 2018-09-11 15:59:25 -04:00 committed by Mac Siri
parent e0b7df7fc5
commit 4451e5eb38
14 changed files with 438 additions and 60 deletions

View file

@ -2,15 +2,91 @@ class Internal::UsersController < Internal::ApplicationController
layout "internal"
def index
@users = User.where.not(feed_url: nil)
@users = case params[:state]
when "mentors"
User.where(offering_mentorship: true).page(params[:page]).per(20)
when "mentees"
User.where(seeking_mentorship: true).page(params[:page]).per(20)
else
User.order("created_at DESC").page(params[:page]).per(20)
end
end
def edit
@user = User.find(params[:id])
end
def show
@user = User.find(params[:id])
@user_mentee_relationships = MentorRelationship.where(mentor_id: @user.id)
@user_mentor_relationships = MentorRelationship.where(mentee_id: @user.id)
end
def update
# Only used for stripping user right now.
@user = User.find(params[:id])
@new_mentee = user_params[:add_mentee]
@new_mentor = user_params[:add_mentor]
handle_mentorship
add_note
@user.update!(user_params)
redirect_to "/internal/users/#{@user.id}"
end
def handle_mentorship
if user_params[:ban_from_mentorship] == "1"
ban_from_mentorship
end
if @new_mentee.blank? && @new_mentor.blank?
return
end
make_matches
end
def make_matches
if !@new_mentee.blank?
mentee = User.find(@new_mentee)
MentorRelationship.new(mentee_id: mentee.id, mentor_id: @user.id).save!
end
if !@new_mentor.blank?
mentor = User.find(@new_mentor)
MentorRelationship.new(mentee_id: @user.id, mentor_id: mentor.id).save!
end
end
def add_note
if user_params[:mentorship_note]
Note.create(
author_id: @current_user.id,
noteable_id: @user.id,
noteable_type: "User",
reason: "mentorship",
content: user_params[:mentorship_note],
)
end
end
def inactive_mentorship(mentor, mentee)
relationship = MentorRelationship.where(mentor_id: mentor.id, mentee_id: mentee.id)
relationship.update(active: false)
end
def ban_from_mentorship
@user.add_role :banned_from_mentorship
mentee_relationships = MentorRelationship.where(mentor_id: @user.id)
mentor_relationships = MentorRelationship.where(mentee_id: @user.id)
deactivate_mentorship(mentee_relationships)
deactivate_mentorship(mentor_relationships)
end
def deactivate_mentorship(relationships)
relationships.each do |relationship|
relationship.update(active: false)
end
end
def banish
@user = User.find(params[:id])
strip_user(@user)
redirect_to "/internal/users/#{@user.id}/edit"
@ -62,4 +138,15 @@ class Internal::UsersController < Internal::ApplicationController
rescue StandardError => e
flash[:error] = e.message
end
private
def user_params
params.require(:user).permit(:seeking_mentorship,
:offering_mentorship,
:add_mentor,
:add_mentee,
:mentorship_note,
:ban_from_mentorship)
end
end

View file

@ -0,0 +1,14 @@
class MentorRelationship < ApplicationRecord
belongs_to :mentor, class_name: "User"
belongs_to :mentee, class_name: "User"
validates :mentor, presence: true
validates :mentee, presence: true
validate :check_for_same_user
validates_uniqueness_of :mentor_id, scope: :mentee_id
def check_for_same_user
if mentor_id == mentee_id
errors.add(:mentor_relationship, "Mentor and Mentee cannot be the same person")
end
end
end

View file

@ -27,6 +27,7 @@ class Role < ApplicationRecord
workshop_pass
video_permission
chatroom_beta_tester
banned_from_mentorship
comment_banned
),
}

View file

@ -1,7 +1,8 @@
class User < ApplicationRecord
include CloudinaryHelper
attr_accessor :scholar_email
attr_accessor :scholar_email, :add_mentor, :add_mentee, :mentorship_note, :ban_from_mentorship
rolify
include AlgoliaSearch
include Storext.model
@ -31,6 +32,16 @@ class User < ApplicationRecord
has_many :chat_channels, through: :chat_channel_memberships
has_many :push_notification_subscriptions, dependent: :destroy
has_many :feedback_messages
has_many :mentor_relationships_as_mentee,
class_name: "MentorRelationship", foreign_key: "mentee_id"
has_many :mentor_relationships_as_mentor,
class_name: "MentorRelationship", foreign_key: "mentor_id"
has_many :mentors,
through: :mentor_relationships_as_mentee,
source: :mentor
has_many :mentees,
through: :mentor_relationships_as_mentor,
source: :mentee
mount_uploader :profile_image, ProfileImageUploader
@ -91,13 +102,14 @@ class User < ApplicationRecord
allow_blank: true
validates :website_url, url: { allow_blank: true, no_local: true, schemes: ["https", "http"] }
validates :website_url, :employer_name, :employer_url,
:employment_title, :education, :location,
length: { maximum: 100 }
validates :mostly_work_with, :currently_learning, :currently_hacking_on,
:available_for,
length: { maximum: 500 }
length: { maximum: 100 }
validates :employment_title, :education, :location,
length: { maximum: 100 }
validates :mostly_work_with, :currently_learning,
:currently_hacking_on, :available_for,
length: { maximum: 500 }
validates :mentee_description, :mentor_description,
length: { maximum: 1000 }
length: { maximum: 1000 }
validate :conditionally_validate_summary
validate :validate_feed_url
validate :unique_including_orgs
@ -107,8 +119,7 @@ class User < ApplicationRecord
after_save :subscribe_to_mailchimp_newsletter
after_save :conditionally_resave_articles
after_create :estimate_default_language!
before_update :mentor_status_update
before_update :mentee_status_update
before_update :mentorship_status_update
before_validation :set_username
before_validation :downcase_email
before_validation :check_for_username_change
@ -255,6 +266,10 @@ class User < ApplicationRecord
has_role? :warned
end
def banned_from_mentorship
has_role? :banned_from_mentorship
end
def admin?
has_role?(:super_admin)
end
@ -515,13 +530,11 @@ class User < ApplicationRecord
follows.destroy_all
end
def mentor_status_update
def mentorship_status_update
if mentor_description_changed? || offering_mentorship_changed?
self.mentor_form_updated_at = Time.now
end
end
def mentee_status_update
if mentee_description_changed? || seeking_mentorship_changed?
self.mentee_form_updated_at = Time.now
end

View file

@ -31,6 +31,21 @@ class UserRoleService
true
end
def create_or_update_note(reason, content)
note = Note.find_by(noteable_id: @user.id, noteable_type: "User", reason: reason)
if note.present?
note.update(content: content)
else
Note.create(
author_id: @current_user_id,
noteable_id: @user.id,
noteable_type: "User",
reason: reason,
content: content,
)
end
end
private
def new_roles?(params)
@ -71,21 +86,6 @@ class UserRoleService
end
end
def create_or_update_note(reason, content)
note = Note.find_by(noteable_id: @user.id, noteable_type: "User", reason: reason)
if note.nil?
Note.create(
author_id: @current_user_id,
noteable_id: @user.id,
noteable_type: "User",
reason: reason,
content: content,
)
else
note.update(content: content)
end
end
def ban(content)
@user.add_role :banned
create_or_update_note("banned", content)

View file

@ -12,10 +12,10 @@
<% else %>
<div class="row">
<% if @user.comments.where("created_at < ?", 7.days.ago).empty? %>
<%= form_for([:internal, @user],confirm: "Are you absolutely sure?") do %>
<%= form_for(@user, :url => banish_internal_user_path(@user), :html => {:method => :post, :onsubmit => "confirm('Are you sure? This is extremely destructive and will change their username to @spam_###. Do not do it lightly.')"}) do %>
<button class="btn btn-danger" style="font-size:2em;">Banish User for Spam!</button>
<% end %>
<em>This is extremely destructive. Do not do it lightly</em>
<em>This is extremely destructive and will change their username to @spam_###. Do not do it lightly.</em>
<% else %>
<em>You cannot take admin action from this view because this is not a newish user. For safety.</em>
<% end %>

View file

@ -1,15 +1,20 @@
<h1>RSS Users</h1>
<h3 class="top-nav">
<a href="/internal/users">All Users</a> ||
<a href="/internal/users?state=mentors">Mentors Only </a>||
<a href="/internal/users?state=mentees">Mentees Only</a>
</h3>
<% if params[:state] == "mentors" %>
<h1>Mentors</h1>
<% elsif params[:state] == "mentees" %>
<h1>Mentees</h1>
<% else %>
<h1>All Users</h1>
<% end %>
<%= paginate @users %>
<% @users.each do |user| %>
<div class="row">
<h2><%= user.name%></h2>
<h3><a href="/<%= user.username %>" target="_blank">@<%= user.username%></a></h3>
<h4><a href="/dashboard/<%= user.username %>" class="btn btn-primary" target="_blank">View dashboard</a></h4>
<hr/>
<h4><a href="<%= user.feed_url %>" target="_blank"><%= user.feed_url%></a></h4>
<% if user.feed_admin_publish_permission %>
<h4 style="color:green">Admin Publish Permission 👌</h4>
<% end%>
</div>
<% end%>
<div class="row">
<h2><%= user.name%></h2>
<h3><a href="/internal/users/<%= user.id %>" target="_blank">@<%= user.username%></a></h3>
</div>
<% end %>
<%= paginate @users %>

View file

@ -0,0 +1,100 @@
<div class="row">
<h1>
<%= @user.name%><a href="/<%= @user.username %>" target="_blank"> (@<%= @user.username %>)</a>
</h1>
<p><em>Member since <%= @user.created_at.strftime("%d %B %Y %H:%M UTC") %></em></p>
<% if @user.banned %>
<p style="background-color:red;color:white">BANNED USER</p>
<p>Reason for banning: <%= @user.reason_for_ban %></p>
<% elsif @user.warned %>
<p style="background-color:orange;color:white">User Status: Warned</p>
<p>Reason for warning: <%= @user.reason_for_warning %></p>
<% else %>
<p style="color:green">Member in good standing</p>
<% end %>
<hr/>
<h3><u>Basic Info</u></h3>
<p><strong>Email:</strong> <%= @user.email %></p>
<% if @user.feed_admin_publish_permission %>
<p><strong>Admins can publish from RSS ✅</strong></p>
<% else %>
<p>Admins cannot publish from RSS ❌<p>
<% end %>
<h3>Badges</h3>
<% if @user.badges.count > 0 %>
<% @user.badges.each do |badge| %>
<img src="<%= badge.badge_image_url %>">
<% end %>
<% end %>
<h3><u>Mentorship Details</u></h3>
<%= form_for ([:internal, @user]) do |f| %>
<% if @user.banned_from_mentorship %>
<p style="background-color:red; color:white;"> USER IS BANNED FROM MENTORSHIP, SEE NOTES BELOW </p>
<% else %>
<p>Ban user from participating in mentorship program <%= f.check_box :ban_from_mentorship %></p>
<% end %>
<%= f.label :offering_mentorship %>
<%= f.check_box :offering_mentorship %>
<br>
<% if @user.mentor_description %>
<p>"<%= @user.mentor_description %>"</p>
<% end %>
<%= f.label :seeking_mentorship %>
<%= f.check_box :seeking_mentorship %>
<br>
<% if @user.mentee_description %>
<p>"<%= @user.mentee_description %>"</p>
<% end %>
<h3><u>Mentorship Matches</u> </h3>
<% if @user.mentors.count > 0 %>
<h4> Mentor(s):</h4>
<ul>
<% @user_mentor_relationships.each do |relationship| %>
<li>
<a href="https://dev.to/<%= User.find(relationship.mentor_id).username %>" target=">blank">@<%= User.find(relationship.mentor_id).username %></a>
<% if relationship.active %>
<span style="color:green"><em> (active)</em></span>
<% else %>
<span style="color:red"><em> (inactive)</em></span>
<% end %>
</li>
<% end %>
</ul>
<% end %>
<br>
<%= f.label :add_mentor %>
<%= f.text_field :add_mentor, placeholder:"user id#" %>
<% if @user.mentees.count > 0 %>
<h4>Mentee(s):</h4>
<ul>
<% @user_mentee_relationships.each do |relationship| %>
<li><a href="https://dev.to/<%= User.find(relationship.mentee_id).username %>" target=">blank">@<%= User.find(relationship.mentee_id).username %></a>
<% if relationship.active %>
<span style="color:green"><em> (active)</em></span>
<% else %>
<span style="color:red"><em> (inactive)</em></span>
<% end %>
</li>
<% end %>
</ul>
<% end %>
<br>
<%= f.label :add_mentee %>
<%= f.text_field :add_mentee, placeholder:"user id#" %>
<h3><u>Mentorship Notes</u></h3>
<% if @user.notes.count > 0 %>
<% @user.notes.each do |note| %>
<p><em><%= note.created_at.strftime("%d %B %Y %H:%M UTC") %> by <%= User.find(note.author_id).username %></em> - <%= note.content %></p>
<% end %>
<br>
<h4>Add new mentorship note</h4>
<%= f.text_area :mentorship_note %>
<% else %>
<%= f.text_area :mentorship_note %>
<% end %>
<br/>
<%= f.submit %>
<% end %>
</div>

View file

@ -27,7 +27,11 @@ Rails.application.routes.draw do
resources :tags
resources :welcome, only: %i[index create]
resources :broadcasts
resources :users
resources :users do
member do
post "banish"
end
end
resources :events
resources :dogfood, only: [:index]
resources :buffer_updates, only: [:create]
@ -43,6 +47,7 @@ Rails.application.routes.draw do
post "save_status", to: :save_status, on: :collection
end
mount Flipflop::Engine => "/features"
end
namespace :api, defaults: { format: "json" } do

View file

@ -0,0 +1,13 @@
class CreateMentorRelationships < ActiveRecord::Migration[5.1]
def change
create_table :mentor_relationships do |t|
t.integer :mentor_id, null: false
t.integer :mentee_id, null: false
t.boolean :active, default: true
t.timestamps
end
add_index :mentor_relationships, :mentee_id
add_index :mentor_relationships, :mentor_id
add_index :mentor_relationships, %i[mentee_id mentor_id], unique: true
end
end

View file

@ -374,6 +374,17 @@ ActiveRecord::Schema.define(version: 20180826174411) do
t.integer "user_id"
end
create_table "mentor_relationships", force: :cascade do |t|
t.boolean "active", default: true
t.datetime "created_at", null: false
t.integer "mentee_id", null: false
t.integer "mentor_id", null: false
t.datetime "updated_at", null: false
t.index ["mentee_id", "mentor_id"], name: "index_mentor_relationships_on_mentee_id_and_mentor_id", unique: true
t.index ["mentee_id"], name: "index_mentor_relationships_on_mentee_id"
t.index ["mentor_id"], name: "index_mentor_relationships_on_mentor_id"
end
create_table "messages", force: :cascade do |t|
t.bigint "chat_channel_id", null: false
t.datetime "created_at", null: false

View file

@ -0,0 +1,23 @@
require "rails_helper"
RSpec.describe MentorRelationship, type: :model do
let(:mentor) { create(:user) }
let(:mentee) { create(:user) }
let(:relationship) { MentorRelationship.create(mentor_id: mentor.id, mentee_id: mentee.id) }
it "is active" do
expect(relationship.active).to eq(true)
end
it { is_expected.to belong_to(:mentor) }
it { is_expected.to belong_to(:mentee) }
it "is not the same user" do
expect(MentorRelationship.create(mentor_id: mentor.id, mentee_id: mentor.id)).to be_invalid
end
it "cannot be a duplicate record" do
relationship
expect(MentorRelationship.create(mentor_id: mentor.id, mentee_id: mentee.id)).to be_invalid
end
end

View file

@ -0,0 +1,100 @@
require "rails_helper"
RSpec.describe "internal/users", type: :request do
let(:mentor) { create(:user) }
let(:mentee) { create(:user) }
let(:admin) { create(:user, :super_admin) }
before do
sign_in(admin)
mentor
mentee
end
describe "GETS /internal/users" do
it "renders to appropriate page" do
get "/internal/users"
expect(response.body).to include(mentor.username)
end
it "only displays mentors on ?state=mentors" do
get "/internal/users?state=mentors"
expect(response.body).not_to include(mentee.username)
end
it "only displays mentees on ?state=mentees" do
get "/internal/users?state=mentees"
expect(response.body).not_to include(mentor.username)
end
end
describe "GET /internal/users/:id" do
it "renders to appropriate page" do
get "/internal/users/#{mentor.id}"
expect(response.body).to include(mentor.username)
end
end
describe "PUT internal/users/:id" do
it "updates user to offer mentorship" do
put "/internal/users/#{mentor.id}",
params: { user: { offering_mentorship: true } }
expect(mentor.reload.offering_mentorship).to eq(true)
end
it "updates user to seek mentorship" do
put "/internal/users/#{mentee.id}", params: { user: { seeking_mentorship: true } }
expect(mentee.reload.seeking_mentorship).to eq(true)
end
it "bans user from mentorship" do
put "/internal/users/#{mentor.id}", params: { user: { ban_from_mentorship: "1" } }
expect(mentor.reload.banned_from_mentorship).to eq(true)
end
it "pairs mentor with a mentee" do
put "/internal/users/#{mentor.id}", params: { user: { add_mentee: mentee.id } }
expect(mentee.mentors[0].id).to eq(mentor.id)
end
it "pairs mentee with a mentor" do
put "/internal/users/#{mentee.id}", params: { user: { add_mentor: mentor.id } }
expect(mentor.mentees[0].id).to eq(mentee.id)
end
it "deactivates existing mentorships when user is banned" do
put "/internal/users/#{mentor.id}", params: { user: { add_mentee: mentee.id } }
put "/internal/users/#{mentor.id}", params: { user: { ban_from_mentorship: "1" } }
expect(MentorRelationship.where(mentor_id: mentor.id)[0].active).to eq(false)
end
it "creates mentorship note" do
put "/internal/users/#{mentor.id}", params: { user: { mentorship_note: "some note" } }
expect(mentor.notes[0].content).to eq("some note")
end
end
describe "GET internal/users/:id/edit" do
it "redirects from /username/moderate" do
get "/#{mentor.username}/moderate"
expect(response).to redirect_to("/internal/users/#{mentor.id}/edit")
end
it "shows banish button for new users" do
get "/internal/users/#{mentor.id}/edit"
expect(response.body).to include("Banish User for Spam!")
end
it "does not show banish button for non-admins" do
sign_out(admin)
expect { get "/internal/users/#{mentor.id}/edit" }.to raise_error(Pundit::NotAuthorizedError)
end
end
describe "PUT internal/users/:id/edit" do
it "bans user for spam" do
post "/internal/users/#{mentor.id}/banish"
expect(mentor.reload.username).to include("spam")
end
end
end

View file

@ -2280,9 +2280,9 @@ commander@^2.11.0, commander@^2.14.1, commander@^2.15.0, commander@^2.9.0:
version "2.18.0"
resolved "https://registry.yarnpkg.com/commander/-/commander-2.18.0.tgz#2bf063ddee7c7891176981a2cc798e5754bc6970"
commander@~2.13.0:
version "2.13.0"
resolved "https://registry.yarnpkg.com/commander/-/commander-2.13.0.tgz#6964bca67685df7c1f1430c584f07d7597885b9c"
commander@~2.14.1:
version "2.14.1"
resolved "http://registry.npmjs.org/commander/-/commander-2.14.1.tgz#2235123e37af8ca3c65df45b026dbd357b01b9aa"
common-tags@^1.7.2:
version "1.8.0"
@ -2680,12 +2680,18 @@ debug@2.6.9, debug@^2.1.2, debug@^2.2.0, debug@^2.3.3, debug@^2.6.0, debug@^2.6.
dependencies:
ms "2.0.0"
debug@^3.1.0:
debug@=3.1.0:
version "3.1.0"
resolved "https://registry.yarnpkg.com/debug/-/debug-3.1.0.tgz#5bb5a0672628b64149566ba16819e61518c67261"
dependencies:
ms "2.0.0"
debug@^3.1.0:
version "3.2.4"
resolved "https://registry.yarnpkg.com/debug/-/debug-3.2.4.tgz#82123737c51afbe9609a2b5dfe9664e7487171f0"
dependencies:
ms "^2.1.1"
decamelize@^1.0.0, decamelize@^1.1.1, decamelize@^1.1.2:
version "1.2.0"
resolved "https://registry.yarnpkg.com/decamelize/-/decamelize-1.2.0.tgz#f6534d15148269b20352e7bee26f501f9a191290"
@ -2864,7 +2870,7 @@ doctrine@^2.0.0, doctrine@^2.1.0:
dom-converter@~0.1:
version "0.1.4"
resolved "https://registry.yarnpkg.com/dom-converter/-/dom-converter-0.1.4.tgz#a45ef5727b890c9bffe6d7c876e7b19cb0e17f3b"
resolved "http://registry.npmjs.org/dom-converter/-/dom-converter-0.1.4.tgz#a45ef5727b890c9bffe6d7c876e7b19cb0e17f3b"
dependencies:
utila "~0.3"
@ -3741,10 +3747,10 @@ flush-write-stream@^1.0.0:
readable-stream "^2.0.4"
follow-redirects@^1.0.0:
version "1.5.7"
resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.5.7.tgz#a39e4804dacb90202bca76a9e2ac10433ca6a69a"
version "1.5.8"
resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.5.8.tgz#1dbfe13e45ad969f813e86c00e5296f525c885a1"
dependencies:
debug "^3.1.0"
debug "=3.1.0"
for-in@^0.1.3:
version "0.1.8"
@ -5946,7 +5952,7 @@ ms@2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8"
ms@2.1.1:
ms@2.1.1, ms@^2.1.1:
version "2.1.1"
resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.1.tgz#30a5864eb3ebb0a66f2ebe6d727af06a09d86e0a"
@ -7637,8 +7643,8 @@ realpath-native@^1.0.0:
util.promisify "^1.0.0"
recast@^0.15.0:
version "0.15.4"
resolved "https://registry.yarnpkg.com/recast/-/recast-0.15.4.tgz#67d51fded6f9ba9afb5e826309816b7cb6452f36"
version "0.15.5"
resolved "https://registry.yarnpkg.com/recast/-/recast-0.15.5.tgz#6871177ee26720be80d7624e4283d5c855a5cb0b"
dependencies:
ast-types "0.11.5"
esprima "~4.0.0"
@ -8913,10 +8919,10 @@ ua-parser-js@^0.7.18:
resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-0.7.18.tgz#a7bfd92f56edfb117083b69e31d2aa8882d4b1ed"
uglify-es@^3.3.4:
version "3.3.9"
resolved "https://registry.yarnpkg.com/uglify-es/-/uglify-es-3.3.9.tgz#0c1c4f0700bed8dbc124cdb304d2592ca203e677"
version "3.3.10"
resolved "https://registry.yarnpkg.com/uglify-es/-/uglify-es-3.3.10.tgz#8b0b7992cebe20edc26de1bf325cef797b8f3fa5"
dependencies:
commander "~2.13.0"
commander "~2.14.1"
source-map "~0.6.1"
uglify-js@3.4.x, uglify-js@^3.1.4: