From d30f454261a21963dc43e21a214b87210ca6e489 Mon Sep 17 00:00:00 2001 From: Guo Xiang Tan Date: Mon, 14 Sep 2015 11:41:22 +0800 Subject: [PATCH 1/4] FEATURE: Create UserProfilerView. --- app/models/user_profile_view.rb | 5 +++++ .../20150914021445_create_user_profile_views.rb | 15 +++++++++++++++ 2 files changed, 20 insertions(+) create mode 100644 app/models/user_profile_view.rb create mode 100644 db/migrate/20150914021445_create_user_profile_views.rb diff --git a/app/models/user_profile_view.rb b/app/models/user_profile_view.rb new file mode 100644 index 00000000000..327da545306 --- /dev/null +++ b/app/models/user_profile_view.rb @@ -0,0 +1,5 @@ +class UserProfileView < ActiveRecord::Base + validates :user_profile_id, presence: true + validates :viewed_at, presence: true + validates :ip_address, presence: true +end diff --git a/db/migrate/20150914021445_create_user_profile_views.rb b/db/migrate/20150914021445_create_user_profile_views.rb new file mode 100644 index 00000000000..04c085a53bb --- /dev/null +++ b/db/migrate/20150914021445_create_user_profile_views.rb @@ -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 From f41bcafe8dbe4d70efbf927d737635641244972e Mon Sep 17 00:00:00 2001 From: Guo Xiang Tan Date: Mon, 14 Sep 2015 11:50:59 +0800 Subject: [PATCH 2/4] FEATURE: Add views to UserProfile. --- app/models/user_profile.rb | 1 + db/migrate/20150914034541_add_views_to_user_profile.rb | 5 +++++ 2 files changed, 6 insertions(+) create mode 100644 db/migrate/20150914034541_add_views_to_user_profile.rb diff --git a/app/models/user_profile.rb b/app/models/user_profile.rb index 2941b922334..aaa5d1f6991 100644 --- a/app/models/user_profile.rb +++ b/app/models/user_profile.rb @@ -112,6 +112,7 @@ end # badge_granted_title :boolean default(FALSE) # card_background :string(255) # card_image_badge_id :integer +# views :integer default(0), not null # # Indexes # diff --git a/db/migrate/20150914034541_add_views_to_user_profile.rb b/db/migrate/20150914034541_add_views_to_user_profile.rb new file mode 100644 index 00000000000..3a13f6c1366 --- /dev/null +++ b/db/migrate/20150914034541_add_views_to_user_profile.rb @@ -0,0 +1,5 @@ +class AddViewsToUserProfile < ActiveRecord::Migration + def change + add_column :user_profiles, :views, :integer, default: 0, null: false + end +end From 7acc93b2a09c897b3a3e778535fa4ce3b1356106 Mon Sep 17 00:00:00 2001 From: Guo Xiang Tan Date: Mon, 14 Sep 2015 15:51:17 +0800 Subject: [PATCH 3/4] FEATURE: Track user profile views. --- .../discourse/controllers/user-card.js.es6 | 1 + .../discourse/templates/user/user.hbs | 1 + app/controllers/users_controller.rb | 14 ++++++ app/models/user_profile.rb | 1 + app/models/user_profile_view.rb | 43 +++++++++++++++++-- app/serializers/user_serializer.rb | 7 ++- config/locales/server.en.yml | 1 + config/site_settings.yml | 1 + spec/controllers/users_controller_spec.rb | 26 ++++++++++- spec/models/user_profile_view_spec.rb | 39 +++++++++++++++++ 10 files changed, 129 insertions(+), 5 deletions(-) create mode 100644 spec/models/user_profile_view_spec.rb diff --git a/app/assets/javascripts/discourse/controllers/user-card.js.es6 b/app/assets/javascripts/discourse/controllers/user-card.js.es6 index a7ade2d7f42..ce2e065a89d 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 28634a17f67..a1f74c5ddae 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 bb361de6fde..a7bc554e179 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 aaa5d1f6991..14aed1333df 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 327da545306..935737173e7 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 334b20e2d24..550d5654d33 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 d4859e82900..73654e2e495 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 efa9b249086..304c59fb91a 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 884f3f3ff20..3c4b7abb1f5 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 00000000000..456cfe16f1a --- /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 From 21725cc90752b8c033cabce5de427a9ccba4b7a5 Mon Sep 17 00:00:00 2001 From: Guo Xiang Tan Date: Tue, 15 Sep 2015 01:30:06 +0800 Subject: [PATCH 4/4] FEATURE: Admin dashboard data for user profile views. --- app/models/admin_dashboard_data.rb | 1 + app/models/report.rb | 8 ++++++++ app/models/user_profile_view.rb | 5 +++++ config/locales/server.en.yml | 4 ++++ 4 files changed, 18 insertions(+) diff --git a/app/models/admin_dashboard_data.rb b/app/models/admin_dashboard_data.rb index 42535237636..33e34d9ffa7 100644 --- a/app/models/admin_dashboard_data.rb +++ b/app/models/admin_dashboard_data.rb @@ -6,6 +6,7 @@ class AdminDashboardData GLOBAL_REPORTS ||= [ 'visits', 'signups', + 'profile_views', 'topics', 'posts', 'time_to_first_response', diff --git a/app/models/report.rb b/app/models/report.rb index 204bc3e9bc5..812d47271ce 100644 --- a/app/models/report.rb +++ b/app/models/report.rb @@ -98,6 +98,14 @@ class Report report_about report, User.real, :count_by_signup_date 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) basic_report_about report, Topic, :listable_count_per_day, report.start_date, report.end_date, report.category_id countable = Topic.listable_topics diff --git a/app/models/user_profile_view.rb b/app/models/user_profile_view.rb index 935737173e7..e3ae1ca7282 100644 --- a/app/models/user_profile_view.rb +++ b/app/models/user_profile_view.rb @@ -39,4 +39,9 @@ class UserProfileView < ActiveRecord::Base 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 diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml index 73654e2e495..f41c27f58b7 100644 --- a/config/locales/server.en.yml +++ b/config/locales/server.en.yml @@ -575,6 +575,10 @@ en: title: "New Users" xaxis: "Day" yaxis: "Number of new users" + profile_views: + title: "User Profile Views" + xaxis: "Day" + yaxis: "Number of user profiles viewed" topics: title: "Topics" xaxis: "Day"