Merge pull request #3767 from tgxworld/track_user_profile_views
Track user profile views
This commit is contained in:
commit
36309e50cc
|
@ -67,6 +67,7 @@ export default Ember.Controller.extend({
|
||||||
|
|
||||||
const args = { stats: false };
|
const args = { stats: false };
|
||||||
args.include_post_count_for = this.get('controllers.topic.model.id');
|
args.include_post_count_for = this.get('controllers.topic.model.id');
|
||||||
|
args.skip_track_visit = true;
|
||||||
|
|
||||||
return Discourse.User.findByUsername(username, args).then((user) => {
|
return Discourse.User.findByUsername(username, args).then((user) => {
|
||||||
if (user.topic_post_count) {
|
if (user.topic_post_count) {
|
||||||
|
|
|
@ -120,6 +120,7 @@
|
||||||
{{#if model.last_seen_at}}
|
{{#if model.last_seen_at}}
|
||||||
<dt>{{i18n 'user.last_seen'}}</dt><dd>{{bound-date model.last_seen_at}}</dd>
|
<dt>{{i18n 'user.last_seen'}}</dt><dd>{{bound-date model.last_seen_at}}</dd>
|
||||||
{{/if}}
|
{{/if}}
|
||||||
|
<dt>{{i18n 'views'}}</dt><dd>{{model.profile_view_count}}</dd>
|
||||||
{{#if model.invited_by}}
|
{{#if model.invited_by}}
|
||||||
<dt>{{i18n 'user.invited_by'}}</dt><dd>{{#link-to 'user' model.invited_by}}{{model.invited_by.username}}{{/link-to}}</dd>
|
<dt>{{i18n 'user.invited_by'}}</dt><dd>{{#link-to 'user' model.invited_by}}{{model.invited_by.username}}{{/link-to}}</dd>
|
||||||
{{/if}}
|
{{/if}}
|
||||||
|
|
|
@ -39,6 +39,10 @@ class UsersController < ApplicationController
|
||||||
user_serializer.topic_post_count = {topic_id => Post.where(topic_id: topic_id, user_id: @user.id).count }
|
user_serializer.topic_post_count = {topic_id => Post.where(topic_id: topic_id, user_id: @user.id).count }
|
||||||
end
|
end
|
||||||
|
|
||||||
|
if !params[:skip_track_visit] && (@user != current_user)
|
||||||
|
track_visit_to_user_profile
|
||||||
|
end
|
||||||
|
|
||||||
# This is a hack to get around a Rails issue where values with periods aren't handled correctly
|
# This is a hack to get around a Rails issue where values with periods aren't handled correctly
|
||||||
# when used as part of a route.
|
# when used as part of a route.
|
||||||
if params[:external_id] and params[:external_id].ends_with? '.json'
|
if params[:external_id] and params[:external_id].ends_with? '.json'
|
||||||
|
@ -650,4 +654,14 @@ class UsersController < ApplicationController
|
||||||
render json: { success: false, message: I18n.t(key) }
|
render json: { success: false, message: I18n.t(key) }
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def track_visit_to_user_profile
|
||||||
|
user_profile_id = @user.user_profile.id
|
||||||
|
ip = request.remote_ip
|
||||||
|
user_id = (current_user.id if current_user)
|
||||||
|
|
||||||
|
Scheduler::Defer.later 'Track profile view visit' do
|
||||||
|
UserProfileView.add(user_profile_id, ip, user_id)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
end
|
end
|
||||||
|
|
|
@ -6,6 +6,7 @@ class AdminDashboardData
|
||||||
GLOBAL_REPORTS ||= [
|
GLOBAL_REPORTS ||= [
|
||||||
'visits',
|
'visits',
|
||||||
'signups',
|
'signups',
|
||||||
|
'profile_views',
|
||||||
'topics',
|
'topics',
|
||||||
'posts',
|
'posts',
|
||||||
'time_to_first_response',
|
'time_to_first_response',
|
||||||
|
|
|
@ -98,6 +98,14 @@ class Report
|
||||||
report_about report, User.real, :count_by_signup_date
|
report_about report, User.real, :count_by_signup_date
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def self.report_profile_views(report)
|
||||||
|
start_date = report.start_date.to_date
|
||||||
|
end_date = report.end_date.to_date
|
||||||
|
basic_report_about report, UserProfileView, :profile_views_by_day, start_date, end_date
|
||||||
|
report.total = UserProfile.sum(:views)
|
||||||
|
report.prev30Days = UserProfileView.where("viewed_at >= ? AND viewed_at < ?", start_date - 30.days, start_date + 1).count
|
||||||
|
end
|
||||||
|
|
||||||
def self.report_topics(report)
|
def self.report_topics(report)
|
||||||
basic_report_about report, Topic, :listable_count_per_day, report.start_date, report.end_date, report.category_id
|
basic_report_about report, Topic, :listable_count_per_day, report.start_date, report.end_date, report.category_id
|
||||||
countable = Topic.listable_topics
|
countable = Topic.listable_topics
|
||||||
|
|
|
@ -7,6 +7,7 @@ class UserProfile < ActiveRecord::Base
|
||||||
after_save :trigger_badges
|
after_save :trigger_badges
|
||||||
|
|
||||||
belongs_to :card_image_badge, class_name: 'Badge'
|
belongs_to :card_image_badge, class_name: 'Badge'
|
||||||
|
has_many :user_profile_views, dependent: :destroy
|
||||||
|
|
||||||
BAKED_VERSION = 1
|
BAKED_VERSION = 1
|
||||||
|
|
||||||
|
@ -112,6 +113,7 @@ end
|
||||||
# badge_granted_title :boolean default(FALSE)
|
# badge_granted_title :boolean default(FALSE)
|
||||||
# card_background :string(255)
|
# card_background :string(255)
|
||||||
# card_image_badge_id :integer
|
# card_image_badge_id :integer
|
||||||
|
# views :integer default(0), not null
|
||||||
#
|
#
|
||||||
# Indexes
|
# Indexes
|
||||||
#
|
#
|
||||||
|
|
|
@ -0,0 +1,47 @@
|
||||||
|
class UserProfileView < ActiveRecord::Base
|
||||||
|
validates_presence_of :user_profile_id, :ip_address, :viewed_at
|
||||||
|
|
||||||
|
belongs_to :user_profile
|
||||||
|
|
||||||
|
def self.add(user_profile_id, ip, user_id=nil, at=nil, skip_redis=false)
|
||||||
|
at ||= Time.zone.now
|
||||||
|
redis_key = "user-profile-view:#{user_profile_id}:#{at.to_date}"
|
||||||
|
if user_id
|
||||||
|
redis_key << ":user-#{user_id}"
|
||||||
|
else
|
||||||
|
redis_key << ":ip-#{ip}"
|
||||||
|
end
|
||||||
|
|
||||||
|
if skip_redis || $redis.setnx(redis_key, '1')
|
||||||
|
skip_redis || $redis.expire(redis_key, SiteSetting.user_profile_view_duration_hours.hours)
|
||||||
|
|
||||||
|
self.transaction do
|
||||||
|
sql = "INSERT INTO user_profile_views (user_profile_id, ip_address, viewed_at, user_id)
|
||||||
|
SELECT :user_profile_id, :ip_address, :viewed_at, :user_id
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM user_profile_views
|
||||||
|
/*where*/
|
||||||
|
)"
|
||||||
|
|
||||||
|
builder = SqlBuilder.new(sql)
|
||||||
|
|
||||||
|
if !user_id
|
||||||
|
builder.where("viewed_at = :viewed_at AND ip_address = :ip_address AND user_profile_id = :user_profile_id AND user_id IS NULL")
|
||||||
|
else
|
||||||
|
builder.where("viewed_at = :viewed_at AND user_id = :user_id AND user_profile_id = :user_profile_id")
|
||||||
|
end
|
||||||
|
|
||||||
|
result = builder.exec(user_profile_id: user_profile_id, ip_address: ip, viewed_at: at, user_id: user_id)
|
||||||
|
|
||||||
|
if result.cmd_tuples > 0
|
||||||
|
UserProfile.find(user_profile_id).increment!(:views)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.profile_views_by_day(start_date, end_date)
|
||||||
|
profile_views = self.where("viewed_at >= ? AND viewed_at < ?", start_date, end_date + 1.day)
|
||||||
|
profile_views.group("date(viewed_at)").order("date(viewed_at)").count
|
||||||
|
end
|
||||||
|
end
|
|
@ -65,7 +65,8 @@ class UserSerializer < BasicUserSerializer
|
||||||
:custom_fields,
|
:custom_fields,
|
||||||
:user_fields,
|
:user_fields,
|
||||||
:topic_post_count,
|
:topic_post_count,
|
||||||
:pending_count
|
:pending_count,
|
||||||
|
:profile_view_count
|
||||||
|
|
||||||
has_one :invited_by, embed: :object, serializer: BasicUserSerializer
|
has_one :invited_by, embed: :object, serializer: BasicUserSerializer
|
||||||
has_many :custom_groups, embed: :object, serializer: BasicGroupSerializer
|
has_many :custom_groups, embed: :object, serializer: BasicGroupSerializer
|
||||||
|
@ -346,4 +347,8 @@ class UserSerializer < BasicUserSerializer
|
||||||
0
|
0
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def profile_view_count
|
||||||
|
object.user_profile.views
|
||||||
|
end
|
||||||
|
|
||||||
end
|
end
|
||||||
|
|
|
@ -576,6 +576,10 @@ en:
|
||||||
title: "New Users"
|
title: "New Users"
|
||||||
xaxis: "Day"
|
xaxis: "Day"
|
||||||
yaxis: "Number of new users"
|
yaxis: "Number of new users"
|
||||||
|
profile_views:
|
||||||
|
title: "User Profile Views"
|
||||||
|
xaxis: "Day"
|
||||||
|
yaxis: "Number of user profiles viewed"
|
||||||
topics:
|
topics:
|
||||||
title: "Topics"
|
title: "Topics"
|
||||||
xaxis: "Day"
|
xaxis: "Day"
|
||||||
|
@ -1074,6 +1078,7 @@ en:
|
||||||
white_listed_spam_host_domains: "A list of domains excluded from spam host testing. New users will never be restricted from creating posts with links to these domains."
|
white_listed_spam_host_domains: "A list of domains excluded from spam host testing. New users will never be restricted from creating posts with links to these domains."
|
||||||
staff_like_weight: "How much extra weighting factor to give staff likes."
|
staff_like_weight: "How much extra weighting factor to give staff likes."
|
||||||
topic_view_duration_hours: "Count a new topic view once per IP/User every N hours"
|
topic_view_duration_hours: "Count a new topic view once per IP/User every N hours"
|
||||||
|
user_profile_view_duration_hours: "Count a new user profile view once per IP/User every N hours"
|
||||||
|
|
||||||
levenshtein_distance_spammer_emails: "When matching spammer emails, number of characters difference that will still allow a fuzzy match."
|
levenshtein_distance_spammer_emails: "When matching spammer emails, number of characters difference that will still allow a fuzzy match."
|
||||||
max_new_accounts_per_registration_ip: "If there are already (n) trust level 0 accounts from this IP (and none is a staff member or at TL2 or higher), stop accepting new signups from that IP."
|
max_new_accounts_per_registration_ip: "If there are already (n) trust level 0 accounts from this IP (and none is a staff member or at TL2 or higher), stop accepting new signups from that IP."
|
||||||
|
|
|
@ -856,6 +856,7 @@ uncategorized:
|
||||||
previous_visit_timeout_hours: 1
|
previous_visit_timeout_hours: 1
|
||||||
staff_like_weight: 3
|
staff_like_weight: 3
|
||||||
topic_view_duration_hours: 8
|
topic_view_duration_hours: 8
|
||||||
|
user_profile_view_duration_hours: 8
|
||||||
|
|
||||||
# Summary mode
|
# Summary mode
|
||||||
summary_score_threshold: 15
|
summary_score_threshold: 15
|
||||||
|
|
|
@ -0,0 +1,15 @@
|
||||||
|
class CreateUserProfileViews < ActiveRecord::Migration
|
||||||
|
def change
|
||||||
|
create_table :user_profile_views do |t|
|
||||||
|
t.integer :user_profile_id, null: false
|
||||||
|
t.datetime :viewed_at, null: false
|
||||||
|
t.inet :ip_address, null: false
|
||||||
|
t.integer :user_id
|
||||||
|
end
|
||||||
|
|
||||||
|
add_index :user_profile_views, :user_profile_id
|
||||||
|
add_index :user_profile_views, :user_id
|
||||||
|
add_index :user_profile_views, [:viewed_at, :ip_address, :user_profile_id], where: "user_id IS NULL", unique: true, name: 'unique_profile_view_ip'
|
||||||
|
add_index :user_profile_views, [:viewed_at, :user_id, :user_profile_id], where: "user_id IS NOT NULL", unique: true, name: 'unique_profile_view_user'
|
||||||
|
end
|
||||||
|
end
|
|
@ -0,0 +1,5 @@
|
||||||
|
class AddViewsToUserProfile < ActiveRecord::Migration
|
||||||
|
def change
|
||||||
|
add_column :user_profiles, :views, :integer, default: 0, null: false
|
||||||
|
end
|
||||||
|
end
|
|
@ -3,7 +3,7 @@ require 'spec_helper'
|
||||||
describe UsersController do
|
describe UsersController do
|
||||||
|
|
||||||
describe '.show' do
|
describe '.show' do
|
||||||
let!(:user) { log_in }
|
let(:user) { log_in }
|
||||||
|
|
||||||
it 'returns success' do
|
it 'returns success' do
|
||||||
xhr :get, :show, username: user.username, format: :json
|
xhr :get, :show, username: user.username, format: :json
|
||||||
|
@ -31,6 +31,30 @@ describe UsersController do
|
||||||
expect(response).to be_forbidden
|
expect(response).to be_forbidden
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe "user profile views" do
|
||||||
|
let(:other_user) { Fabricate(:user) }
|
||||||
|
|
||||||
|
it "should track a user profile view for a signed in user" do
|
||||||
|
UserProfileView.expects(:add).with(other_user.user_profile.id, request.remote_ip, user.id)
|
||||||
|
xhr :get, :show, username: other_user.username
|
||||||
|
end
|
||||||
|
|
||||||
|
it "should not track a user profile view for a user viewing his own profile" do
|
||||||
|
UserProfileView.expects(:add).never
|
||||||
|
xhr :get, :show, username: user.username
|
||||||
|
end
|
||||||
|
|
||||||
|
it "should track a user profile view for an anon user" do
|
||||||
|
UserProfileView.expects(:add).with(other_user.user_profile.id, request.remote_ip, nil)
|
||||||
|
xhr :get, :show, username: other_user.username
|
||||||
|
end
|
||||||
|
|
||||||
|
it "skips tracking" do
|
||||||
|
UserProfileView.expects(:add).never
|
||||||
|
xhr :get, :show, { username: user.username, skip_track_visit: true }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
context "fetching a user by external_id" do
|
context "fetching a user by external_id" do
|
||||||
before { user.create_single_sign_on_record(external_id: '997', last_payload: '') }
|
before { user.create_single_sign_on_record(external_id: '997', last_payload: '') }
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,39 @@
|
||||||
|
require 'spec_helper'
|
||||||
|
|
||||||
|
RSpec.describe UserProfileView do
|
||||||
|
let(:user) { Fabricate(:user) }
|
||||||
|
let(:other_user) { Fabricate(:user) }
|
||||||
|
let(:user_profile_id) { user.user_profile.id }
|
||||||
|
|
||||||
|
def add(user_profile_id, ip, user_id=nil, at=nil)
|
||||||
|
described_class.add(user_profile_id, ip, user_id, at, true)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "should increase user's profile view count" do
|
||||||
|
expect{ add(user_profile_id, '1.1.1.1') }.to change{ described_class.count }.by(1)
|
||||||
|
expect(user.user_profile.reload.views).to eq(1)
|
||||||
|
expect{ add(user_profile_id, '1.1.1.1', other_user.id) }.to change{ described_class.count }.by(1)
|
||||||
|
|
||||||
|
user_profile = user.user_profile.reload
|
||||||
|
expect(user_profile.views).to eq(2)
|
||||||
|
expect(user_profile.user_profile_views).to eq(described_class.all)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "should not create duplicated profile view for anon user" do
|
||||||
|
time = Time.zone.now
|
||||||
|
|
||||||
|
2.times do
|
||||||
|
add(user_profile_id, '1.1.1.1', nil, time)
|
||||||
|
expect(described_class.count).to eq(1)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
it "should not create duplicated profile view for signed in user" do
|
||||||
|
time = Time.zone.now
|
||||||
|
|
||||||
|
['1.1.1.1', '2.2.2.2'].each do |ip|
|
||||||
|
add(user_profile_id, ip, other_user.id, time)
|
||||||
|
expect(described_class.count).to eq(1)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
Loading…
Reference in New Issue