From e58cf24fcc645de92316f865d0dda94ccaddb8d1 Mon Sep 17 00:00:00 2001 From: Martin Brennan Date: Tue, 9 Jul 2024 15:39:10 +1000 Subject: [PATCH] FEATURE: Topic view stats report (#27760) Adds a report to show the top 100 most viewed topics in a date range, combining logged in and anonymous views. Can be filtered by category. This is a followup to 527f02e99fee782f53e206f739fb4f12d63b6d2a and d1191b7f5fbe9e8ea1b645d5a61c79fa93693c0c. We are also going to be able to see this data in a new topic map, but this admin report helps to see an overview across the forum for a date range. --- .../concerns/reports/topic_view_stats.rb | 75 +++++++++++ app/models/report.rb | 1 + config/locales/server.en.yml | 8 ++ .../fabricators/topic_view_stat_fabricator.rb | 8 ++ spec/models/report_spec.rb | 127 +++++++++++++++--- 5 files changed, 200 insertions(+), 19 deletions(-) create mode 100644 app/models/concerns/reports/topic_view_stats.rb create mode 100644 spec/fabricators/topic_view_stat_fabricator.rb diff --git a/app/models/concerns/reports/topic_view_stats.rb b/app/models/concerns/reports/topic_view_stats.rb new file mode 100644 index 00000000000..ce33f30d3ab --- /dev/null +++ b/app/models/concerns/reports/topic_view_stats.rb @@ -0,0 +1,75 @@ +# frozen_string_literal: true + +module Reports::TopicViewStats + extend ActiveSupport::Concern + + class_methods do + def report_topic_view_stats(report) + report.modes = [:table] + + category_id, include_subcategories = report.add_category_filter + + category_ids = include_subcategories ? Category.subcategory_ids(category_id) : [category_id] + sql = <<~SQL + SELECT + topic_view_stats.topic_id, + topics.title AS topic_title, + SUM(topic_view_stats.anonymous_views) AS total_anonymous_views, + SUM(topic_view_stats.logged_in_views) AS total_logged_in_views, + SUM(topic_view_stats.anonymous_views + topic_view_stats.logged_in_views) AS total_views + FROM topic_view_stats + INNER JOIN topics ON topics.id = topic_view_stats.topic_id + WHERE viewed_at BETWEEN :start_date AND :end_date + #{category_ids.any? ? "AND topics.category_id IN (:category_ids)" : ""} + GROUP BY topic_view_stats.topic_id, topics.title + ORDER BY total_views DESC + LIMIT 100 + SQL + + data = + DB.query( + sql, + start_date: report.start_date, + end_date: report.end_date, + category_ids: category_ids, + ) + + report.labels = [ + { + type: :topic, + properties: { + title: :topic_title, + id: :topic_id, + }, + title: I18n.t("reports.topic_view_stats.labels.topic"), + }, + { + property: :total_anonymous_views, + type: :number, + title: I18n.t("reports.topic_view_stats.labels.anon_views"), + }, + { + property: :total_logged_in_views, + type: :number, + title: I18n.t("reports.topic_view_stats.labels.logged_in_views"), + }, + { + property: :total_views, + type: :number, + title: I18n.t("reports.topic_view_stats.labels.total_views"), + }, + ] + + report.data = + data.map do |row| + { + topic_id: row.topic_id, + topic_title: row.topic_title, + total_anonymous_views: row.total_anonymous_views, + total_logged_in_views: row.total_logged_in_views, + total_views: row.total_views, + } + end + end + end +end diff --git a/app/models/report.rb b/app/models/report.rb index 41e7df7ca40..613d4bc585c 100644 --- a/app/models/report.rb +++ b/app/models/report.rb @@ -51,6 +51,7 @@ class Report include Reports::TopUsersByLikesReceivedFromInferiorTrustLevel include Reports::Topics include Reports::TopicsWithNoResponse + include Reports::TopicViewStats include Reports::TrendingSearch include Reports::TrustLevelGrowth include Reports::UserFlaggingRatio diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml index 3f48bd0f6c8..f90df56fb23 100644 --- a/config/locales/server.en.yml +++ b/config/locales/server.en.yml @@ -1630,6 +1630,14 @@ en: user: User qtt_like: Likes Received description: "Top 10 users who have had likes from a wide range of people." + topic_view_stats: + title: "Topic View Stats" + labels: + topic: Topic + logged_in_views: Logged In + anon_views: Anonymous + total_views: Total + description: "The top 100 most viewed topics in a date range, combining logged in and anonymous views. Can be filtered by category." dashboard: problem: diff --git a/spec/fabricators/topic_view_stat_fabricator.rb b/spec/fabricators/topic_view_stat_fabricator.rb new file mode 100644 index 00000000000..818ac74fa55 --- /dev/null +++ b/spec/fabricators/topic_view_stat_fabricator.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +Fabricator(:topic_view_stat) do + topic { Fabricate(:topic) } + viewed_at { Time.zone.now } + anonymous_views { 1 } + logged_in_views { 1 } +end diff --git a/spec/models/report_spec.rb b/spec/models/report_spec.rb index 7021990c043..79f0d6f98ef 100644 --- a/spec/models/report_spec.rb +++ b/spec/models/report_spec.rb @@ -2,9 +2,9 @@ RSpec.describe Report do let(:user) { Fabricate(:user) } - let(:c0) { Fabricate(:category, user: user) } - let(:c1) { Fabricate(:category, parent_category: c0, user: user) } # id: 2 - let(:c2) { Fabricate(:category, user: user) } + let(:category_1) { Fabricate(:category, user: user) } + let(:category_2) { Fabricate(:category, parent_category: category_1, user: user) } # id: 2 + let(:category_3) { Fabricate(:category, user: user) } shared_examples "no data" do context "with no data" do @@ -894,7 +894,8 @@ RSpec.describe Report do user = Fabricate(:user, refresh_auto_groups: true) topic = Fabricate(:topic, user: user) post0 = Fabricate(:post, topic: topic, user: user) - post1 = Fabricate(:post, topic: Fabricate(:topic, category: c1, user: user), user: user) + post1 = + Fabricate(:post, topic: Fabricate(:topic, category: category_2, user: user), user: user) post2 = Fabricate(:post, topic: topic, user: user) post3 = Fabricate(:post, topic: topic, user: user) PostActionCreator.off_topic(user, post0) @@ -904,13 +905,13 @@ RSpec.describe Report do end context "with category filtering" do - let(:report) { Report.find("flags", filters: { category: c1.id }) } + let(:report) { Report.find("flags", filters: { category: category_2.id }) } include_examples "category filtering" context "with subcategories" do let(:report) do - Report.find("flags", filters: { category: c0.id, include_subcategories: true }) + Report.find("flags", filters: { category: category_1.id, include_subcategories: true }) end include_examples "category filtering on subcategories" @@ -930,19 +931,19 @@ RSpec.describe Report do before(:each) do user = Fabricate(:user) Fabricate(:topic, user: user) - Fabricate(:topic, category: c1, user: user) + Fabricate(:topic, category: category_2, user: user) Fabricate(:topic, user: user) Fabricate(:topic, created_at: 45.days.ago, user: user) end context "with category filtering" do - let(:report) { Report.find("topics", filters: { category: c1.id }) } + let(:report) { Report.find("topics", filters: { category: category_2.id }) } include_examples "category filtering" context "with subcategories" do let(:report) do - Report.find("topics", filters: { category: c0.id, include_subcategories: true }) + Report.find("topics", filters: { category: category_1.id, include_subcategories: true }) end include_examples "category filtering on subcategories" @@ -1017,7 +1018,7 @@ RSpec.describe Report do before(:each) do user = Fabricate(:user) topic = Fabricate(:topic, user: user) - topic_with_category_id = Fabricate(:topic, category: c1, user: user) + topic_with_category_id = Fabricate(:topic, category: category_2, user: user) Fabricate(:post, topic: topic, user: user) Fabricate(:post, topic: topic_with_category_id, user: user) Fabricate(:post, topic: topic, user: user) @@ -1025,13 +1026,13 @@ RSpec.describe Report do end context "with category filtering" do - let(:report) { Report.find("posts", filters: { category: c1.id }) } + let(:report) { Report.find("posts", filters: { category: category_2.id }) } include_examples "category filtering" context "with subcategories" do let(:report) do - Report.find("posts", filters: { category: c0.id, include_subcategories: true }) + Report.find("posts", filters: { category: category_1.id, include_subcategories: true }) end include_examples "category filtering on subcategories" @@ -1052,14 +1053,16 @@ RSpec.describe Report do before(:each) do user = Fabricate(:user) - Fabricate(:topic, category: c1, user: user) + Fabricate(:topic, category: category_2, user: user) Fabricate(:post, topic: Fabricate(:topic, user: user), user: user) Fabricate(:topic, user: user) Fabricate(:topic, created_at: 45.days.ago, user: user) end context "with category filtering" do - let(:report) { Report.find("topics_with_no_response", filters: { category: c1.id }) } + let(:report) do + Report.find("topics_with_no_response", filters: { category: category_2.id }) + end include_examples "category filtering" @@ -1068,7 +1071,7 @@ RSpec.describe Report do Report.find( "topics_with_no_response", filters: { - category: c0.id, + category: category_1.id, include_subcategories: true, }, ) @@ -1089,11 +1092,11 @@ RSpec.describe Report do include_examples "with data x/y" before(:each) do - topic = Fabricate(:topic, category: c1) + topic = Fabricate(:topic, category: category_2) post = Fabricate(:post, topic: topic) PostActionCreator.like(Fabricate(:user), post) - topic = Fabricate(:topic, category: c2) + topic = Fabricate(:topic, category: category_3) post = Fabricate(:post, topic: topic) PostActionCreator.like(Fabricate(:user), post) PostActionCreator.like(Fabricate(:user), post) @@ -1105,13 +1108,13 @@ RSpec.describe Report do end context "with category filtering" do - let(:report) { Report.find("likes", filters: { category: c1.id }) } + let(:report) { Report.find("likes", filters: { category: category_2.id }) } include_examples "category filtering" context "with subcategories" do let(:report) do - Report.find("likes", filters: { category: c0.id, include_subcategories: true }) + Report.find("likes", filters: { category: category_1.id, include_subcategories: true }) end include_examples "category filtering on subcategories" @@ -1732,4 +1735,90 @@ RSpec.describe Report do end end end + + describe "topic_view_stats" do + let(:report) { Report.find("topic_view_stats") } + + fab!(:topic_1) { Fabricate(:topic) } + fab!(:topic_2) { Fabricate(:topic) } + + include_examples "no data" + + context "with data" do + before do + freeze_time_safe + + Fabricate( + :topic_view_stat, + topic: topic_1, + anonymous_views: 4, + logged_in_views: 2, + viewed_at: Time.zone.now - 5.days, + ) + Fabricate( + :topic_view_stat, + topic: topic_1, + anonymous_views: 5, + logged_in_views: 18, + viewed_at: Time.zone.now - 3.days, + ) + Fabricate( + :topic_view_stat, + topic: topic_2, + anonymous_views: 14, + logged_in_views: 21, + viewed_at: Time.zone.now - 5.days, + ) + Fabricate( + :topic_view_stat, + topic: topic_2, + anonymous_views: 9, + logged_in_views: 13, + viewed_at: Time.zone.now - 1.days, + ) + Fabricate( + :topic_view_stat, + topic: Fabricate(:topic), + anonymous_views: 1, + logged_in_views: 34, + viewed_at: Time.zone.now - 40.days, + ) + end + + it "works" do + expect(report.data.length).to eq(2) + expect(report.data[0]).to include( + topic_id: topic_2.id, + topic_title: topic_2.title, + total_anonymous_views: 23, + total_logged_in_views: 34, + total_views: 57, + ) + expect(report.data[1]).to include( + topic_id: topic_1.id, + topic_title: topic_1.title, + total_anonymous_views: 9, + total_logged_in_views: 20, + total_views: 29, + ) + end + + context "with category filtering" do + let(:report) { Report.find("topic_view_stats", filters: { category: category_1.id }) } + + before { topic_1.update!(category: category_1) } + + it "filters topics to that category" do + expect(report.data.length).to eq(1) + expect(report.data[0]).to include( + topic_id: topic_1.id, + topic_title: topic_1.title, + total_anonymous_views: 9, + total_logged_in_views: 20, + total_views: 29, + ) + end + end + end + end end