diff --git a/app/assets/javascripts/discourse/controllers/user-card.js.es6 b/app/assets/javascripts/discourse/controllers/user-card.js.es6
index a7ade2d7f..ce2e065a8 100644
--- a/app/assets/javascripts/discourse/controllers/user-card.js.es6
+++ b/app/assets/javascripts/discourse/controllers/user-card.js.es6
@@ -67,6 +67,7 @@ export default Ember.Controller.extend({
const args = { stats: false };
args.include_post_count_for = this.get('controllers.topic.model.id');
+ args.skip_track_visit = true;
return Discourse.User.findByUsername(username, args).then((user) => {
if (user.topic_post_count) {
diff --git a/app/assets/javascripts/discourse/templates/user/user.hbs b/app/assets/javascripts/discourse/templates/user/user.hbs
index 28634a17f..a1f74c5dd 100644
--- a/app/assets/javascripts/discourse/templates/user/user.hbs
+++ b/app/assets/javascripts/discourse/templates/user/user.hbs
@@ -120,6 +120,7 @@
{{#if model.last_seen_at}}
{{i18n 'user.last_seen'}}{{bound-date model.last_seen_at}}
{{/if}}
+ {{i18n 'views'}}{{model.profile_view_count}}
{{#if model.invited_by}}
{{i18n 'user.invited_by'}}{{#link-to 'user' model.invited_by}}{{model.invited_by.username}}{{/link-to}}
{{/if}}
diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb
index bb361de6f..a7bc554e1 100644
--- a/app/controllers/users_controller.rb
+++ b/app/controllers/users_controller.rb
@@ -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 }
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
# when used as part of a route.
if params[:external_id] and params[:external_id].ends_with? '.json'
@@ -641,4 +645,14 @@ class UsersController < ApplicationController
render json: { success: false, message: I18n.t(key) }
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
diff --git a/app/models/user_profile.rb b/app/models/user_profile.rb
index aaa5d1f69..14aed1333 100644
--- a/app/models/user_profile.rb
+++ b/app/models/user_profile.rb
@@ -7,6 +7,7 @@ class UserProfile < ActiveRecord::Base
after_save :trigger_badges
belongs_to :card_image_badge, class_name: 'Badge'
+ has_many :user_profile_views, dependent: :destroy
BAKED_VERSION = 1
diff --git a/app/models/user_profile_view.rb b/app/models/user_profile_view.rb
index 327da5453..935737173 100644
--- a/app/models/user_profile_view.rb
+++ b/app/models/user_profile_view.rb
@@ -1,5 +1,42 @@
class UserProfileView < ActiveRecord::Base
- validates :user_profile_id, presence: true
- validates :viewed_at, presence: true
- validates :ip_address, presence: true
+ 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
end
diff --git a/app/serializers/user_serializer.rb b/app/serializers/user_serializer.rb
index 334b20e2d..550d5654d 100644
--- a/app/serializers/user_serializer.rb
+++ b/app/serializers/user_serializer.rb
@@ -65,7 +65,8 @@ class UserSerializer < BasicUserSerializer
:custom_fields,
:user_fields,
:topic_post_count,
- :pending_count
+ :pending_count,
+ :profile_view_count
has_one :invited_by, embed: :object, serializer: BasicUserSerializer
has_many :custom_groups, embed: :object, serializer: BasicGroupSerializer
@@ -346,4 +347,8 @@ class UserSerializer < BasicUserSerializer
0
end
+ def profile_view_count
+ object.user_profile.views
+ end
+
end
diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml
index d4859e829..73654e2e4 100644
--- a/config/locales/server.en.yml
+++ b/config/locales/server.en.yml
@@ -1072,6 +1072,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."
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"
+ 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."
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."
diff --git a/config/site_settings.yml b/config/site_settings.yml
index efa9b2490..304c59fb9 100644
--- a/config/site_settings.yml
+++ b/config/site_settings.yml
@@ -853,6 +853,7 @@ uncategorized:
previous_visit_timeout_hours: 1
staff_like_weight: 3
topic_view_duration_hours: 8
+ user_profile_view_duration_hours: 8
# Summary mode
summary_score_threshold: 15
diff --git a/spec/controllers/users_controller_spec.rb b/spec/controllers/users_controller_spec.rb
index 884f3f3ff..3c4b7abb1 100644
--- a/spec/controllers/users_controller_spec.rb
+++ b/spec/controllers/users_controller_spec.rb
@@ -3,7 +3,7 @@ require 'spec_helper'
describe UsersController do
describe '.show' do
- let!(:user) { log_in }
+ let(:user) { log_in }
it 'returns success' do
xhr :get, :show, username: user.username, format: :json
@@ -31,6 +31,30 @@ describe UsersController do
expect(response).to be_forbidden
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
before { user.create_single_sign_on_record(external_id: '997', last_payload: '') }
diff --git a/spec/models/user_profile_view_spec.rb b/spec/models/user_profile_view_spec.rb
new file mode 100644
index 000000000..456cfe16f
--- /dev/null
+++ b/spec/models/user_profile_view_spec.rb
@@ -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