mirror of
https://github.com/discourse/discourse.git
synced 2026-08-06 10:47:18 +08:00
DAU/MAU reports previously calculated every requested date from the full user visit history, so dashboard requests became slower as that history grew. Persist the daily DAU and trailing-30-day MAU values and have the shared report read only those rollups. The scheduled job builds all existing history on its first successful run, then refreshes yesterday and today every three hours. Replacements are atomic, so a failed calculation leaves the previous report data intact.
555 lines
18 KiB
Ruby
Vendored
555 lines
18 KiB
Ruby
Vendored
# frozen_string_literal: true
|
|
|
|
RSpec.describe Jobs::ExportCsvFile do
|
|
describe "#execute" do
|
|
let(:other_user) { Fabricate(:user) }
|
|
let(:admin) { Fabricate(:admin) }
|
|
let(:action_log) { StaffActionLogger.new(admin).log_revoke_moderation(other_user) }
|
|
|
|
it "raises an error when the entity is missing" do
|
|
expect { Jobs::ExportCsvFile.new.execute(user_id: admin.id) }.to raise_error(
|
|
Discourse::InvalidParameters,
|
|
)
|
|
end
|
|
|
|
context "when re-validating permissions at execution time" do
|
|
it "exports the user list for an admin" do
|
|
expect do
|
|
Jobs::ExportCsvFile.new.execute(user_id: admin.id, entity: "user_list")
|
|
end.to change { Upload.count }.by(1)
|
|
ensure
|
|
admin.uploads.each(&:destroy!)
|
|
end
|
|
|
|
it "raises an error when the admin was demoted after enqueueing" do
|
|
admin.revoke_admin!
|
|
|
|
expect do
|
|
Jobs::ExportCsvFile.new.execute(user_id: admin.id, entity: "user_list")
|
|
end.to raise_error(Discourse::InvalidAccess)
|
|
expect(UserExport.where(user_id: admin.id)).to be_empty
|
|
end
|
|
|
|
it "raises an error when a regular user attempts a privileged export" do
|
|
user = Fabricate(:user)
|
|
|
|
expect do
|
|
Jobs::ExportCsvFile.new.execute(user_id: user.id, entity: "user_list")
|
|
end.to raise_error(Discourse::InvalidAccess)
|
|
expect(UserExport.where(user_id: user.id)).to be_empty
|
|
end
|
|
|
|
it "raises an error when a moderator attempts an admin-only export" do
|
|
moderator = Fabricate(:moderator)
|
|
|
|
expect do
|
|
Jobs::ExportCsvFile.new.execute(user_id: moderator.id, entity: "user_list")
|
|
end.to raise_error(Discourse::InvalidAccess)
|
|
expect(UserExport.where(user_id: moderator.id)).to be_empty
|
|
end
|
|
end
|
|
|
|
it "works" do
|
|
action_log
|
|
|
|
begin
|
|
expect do
|
|
Jobs::ExportCsvFile.new.execute(user_id: admin.id, entity: "staff_action")
|
|
end.to change { Upload.count }.by(1)
|
|
|
|
system_message = admin.topics_allowed.last
|
|
|
|
expect(system_message.title).to eq(
|
|
I18n.t(
|
|
"system_messages.csv_export_succeeded.subject_template",
|
|
export_title: "Staff Action",
|
|
),
|
|
)
|
|
|
|
upload = system_message.first_post.uploads.first
|
|
|
|
expect(system_message.first_post.raw).to eq(
|
|
I18n.t(
|
|
"system_messages.csv_export_succeeded.text_body_template",
|
|
download_link: UploadMarkdown.new(upload).attachment_markdown,
|
|
).chomp,
|
|
)
|
|
|
|
expect(system_message.id).to eq(UserExport.last.topic_id)
|
|
expect(system_message.closed).to eq(true)
|
|
|
|
files = []
|
|
Zip::File.open(Discourse.store.path_for(upload)) do |zip_file|
|
|
zip_file.each { |entry| files << entry.name }
|
|
end
|
|
|
|
expect(files.size).to eq(1)
|
|
ensure
|
|
admin.uploads.each(&:destroy!)
|
|
end
|
|
end
|
|
|
|
it "generates csv export failed message if upload is too large" do
|
|
action_log
|
|
SiteSetting.max_export_file_size_kb = 0
|
|
|
|
begin
|
|
Jobs::ExportCsvFile.new.execute(user_id: admin.id, entity: "staff_action")
|
|
|
|
system_message = admin.topics_allowed.last
|
|
|
|
expect(system_message.title).to eq(
|
|
I18n.t(
|
|
"system_messages.csv_export_failed.subject_template",
|
|
export_title: "Data export failed",
|
|
),
|
|
)
|
|
ensure
|
|
admin.uploads.each(&:destroy!)
|
|
end
|
|
end
|
|
|
|
context "when exporting staff action" do
|
|
it "exports staff action logs with date filters" do
|
|
freeze_time
|
|
(1..10).each do |i|
|
|
Fabricate(
|
|
:user_history,
|
|
action: UserHistory.actions[:suspend_user],
|
|
created_at: i.days.ago,
|
|
)
|
|
end
|
|
|
|
expect do
|
|
Jobs::ExportCsvFile.new.execute(
|
|
user_id: admin.id,
|
|
entity: "staff_action",
|
|
args: {
|
|
# Fine-tuning for 1 minute to ensure we capture the correct range
|
|
start_date: (5.days.ago - 1.minute).iso8601,
|
|
end_date: (2.days.ago + 1.minute).iso8601,
|
|
},
|
|
)
|
|
end.to change { Upload.count }.by(1)
|
|
|
|
Zip::File.open(Discourse.store.path_for(Upload.last)) do |zip_file|
|
|
zip_file.each do |entry|
|
|
content = zip_file.read(entry)
|
|
expect(CSV.parse(content).size).to eq(5) # [header, 2, 3, 4, 5]
|
|
end
|
|
end
|
|
end
|
|
|
|
it "delegates to UserHistory.staff_action_records" do
|
|
Fabricate(:user_history, action: UserHistory.actions[:suspend_user])
|
|
Fabricate(:user_history, action: UserHistory.actions[:change_site_setting])
|
|
Fabricate(:user_history, action: UserHistory.actions[:delete_theme])
|
|
|
|
res =
|
|
UserHistory.staff_action_records(
|
|
admin,
|
|
action_id: UserHistory.actions[:suspend_user].to_s,
|
|
)
|
|
|
|
UserHistory
|
|
.expects(:staff_action_records)
|
|
.with(
|
|
admin,
|
|
ActiveSupport::HashWithIndifferentAccess.new(
|
|
"action_id" => UserHistory.actions[:suspend_user].to_s,
|
|
),
|
|
)
|
|
.returns(res)
|
|
|
|
Jobs::ExportCsvFile.new.execute(
|
|
user_id: admin.id,
|
|
entity: "staff_action",
|
|
args: {
|
|
"action_id" => UserHistory.actions[:suspend_user].to_s,
|
|
},
|
|
)
|
|
|
|
Zip::File.open(Discourse.store.path_for(Upload.last)) do |zip_file|
|
|
zip_file.each do |entry|
|
|
content = zip_file.read(entry)
|
|
expect(CSV.parse(content).size).to eq(2)
|
|
end
|
|
end
|
|
end
|
|
|
|
it "redacts details and context for moderators who cannot see the log content" do
|
|
category = Fabricate(:private_category, group: Fabricate(:group))
|
|
topic = Fabricate(:topic, category: category)
|
|
post = Fabricate(:post, topic: topic)
|
|
moderator = Fabricate(:moderator)
|
|
action_log =
|
|
StaffActionLogger.new(admin).log_post_edit(
|
|
post,
|
|
old_raw: "#{post.raw} old",
|
|
context: "secret context",
|
|
)
|
|
|
|
Jobs::ExportCsvFile.new.execute(user_id: moderator.id, entity: "staff_action")
|
|
|
|
row = parse_staff_action_rows(Upload.last).find { |r| r["action"] == "post_edit" }
|
|
expect(row).to be_present
|
|
expect(row["details"]).to eq(I18n.t("staff_action_logs.redacted"))
|
|
expect(row["context"]).to be_blank
|
|
|
|
action_log.destroy!
|
|
end
|
|
|
|
it "does not redact details and context for admins" do
|
|
post = Fabricate(:post)
|
|
StaffActionLogger.new(admin).log_post_edit(
|
|
post,
|
|
old_raw: "#{post.raw} old",
|
|
context: "visible context",
|
|
)
|
|
|
|
Jobs::ExportCsvFile.new.execute(user_id: admin.id, entity: "staff_action")
|
|
|
|
row = parse_staff_action_rows(Upload.last).find { |r| r["action"] == "post_edit" }
|
|
expect(row).to be_present
|
|
expect(row["details"]).to include("---")
|
|
expect(row["context"]).to include("visible context")
|
|
end
|
|
end
|
|
end
|
|
|
|
describe ".report_export" do
|
|
let(:user) { Fabricate(:admin) }
|
|
|
|
let(:exporter) do
|
|
exporter = Jobs::ExportCsvFile.new
|
|
exporter.entity = "report"
|
|
exporter.extra =
|
|
ActiveSupport::HashWithIndifferentAccess.new(
|
|
start_date: "2010-01-01",
|
|
end_date: "2011-01-01",
|
|
)
|
|
exporter.current_user = User.find_by(id: user.id)
|
|
exporter
|
|
end
|
|
|
|
it "does not throw an error when the dates are invalid" do
|
|
Jobs::ExportCsvFile.new.execute(
|
|
entity: "report",
|
|
user_id: user.id,
|
|
args: {
|
|
start_date: "asdfasdf",
|
|
end_date: "not-a-date",
|
|
name: "dau_by_mau",
|
|
},
|
|
)
|
|
end
|
|
|
|
it "works with single-column reports" do
|
|
Fabricate(:user_visit_daily_rollup, date: Date.new(2010, 1, 1))
|
|
Fabricate(:user_visit_daily_rollup, date: Date.new(2010, 1, 3), mau: 2)
|
|
exporter.extra["name"] = "dau_by_mau"
|
|
|
|
report = export_report
|
|
|
|
expect(report.first).to contain_exactly("Day", "Percent")
|
|
expect(report.second).to contain_exactly("2010-01-01", "100.0")
|
|
expect(report.third).to contain_exactly("2010-01-03", "50.0")
|
|
end
|
|
|
|
it "works with filters" do
|
|
user.user_visits.create!(visited_at: "2010-01-01", posts_read: 42)
|
|
|
|
group = Fabricate(:group)
|
|
user1 = Fabricate(:user)
|
|
Fabricate(:group_user, group: group, user: user1)
|
|
user1.user_visits.create!(visited_at: "2010-01-03", posts_read: 420)
|
|
|
|
exporter.extra["name"] = "visits"
|
|
exporter.extra["group"] = group.id
|
|
|
|
report = export_report
|
|
|
|
expect(report.length).to eq(2)
|
|
expect(report.first).to contain_exactly("Day", "Desktop", "Mobile")
|
|
expect(report.second).to contain_exactly("2010-01-03", "1", "")
|
|
end
|
|
|
|
it "works with single-column reports with default label" do
|
|
user.user_visits.create!(visited_at: "2010-01-01", mobile: true)
|
|
Fabricate(:user).user_visits.create!(visited_at: "2010-01-03", mobile: true)
|
|
exporter.extra["name"] = "mobile_visits"
|
|
|
|
report = export_report
|
|
|
|
expect(report.first).to contain_exactly("Day", "Count")
|
|
expect(report.second).to contain_exactly("2010-01-01", "1")
|
|
expect(report.third).to contain_exactly("2010-01-03", "1")
|
|
end
|
|
|
|
it "works with multi-columns reports" do
|
|
DiscourseIpInfo.stubs(:get).with("1.1.1.1").returns(location: "Earth")
|
|
user.user_auth_token_logs.create!(
|
|
action: "login",
|
|
client_ip: "1.1.1.1",
|
|
created_at: "2010-01-01",
|
|
)
|
|
exporter.extra["name"] = "admin_logins"
|
|
|
|
report = export_report
|
|
|
|
expect(report.first).to contain_exactly("User", "Location", "Login at")
|
|
expect(report.second).to contain_exactly(user.username, "Earth", "2010-01-01 00:00:00 UTC")
|
|
end
|
|
|
|
it "exports suspicious login IP details when allowed" do
|
|
DiscourseIpInfo.stubs(:get).with("1.1.1.1").returns(location: "Earth")
|
|
user.user_auth_token_logs.create!(
|
|
action: "suspicious",
|
|
client_ip: "1.1.1.1",
|
|
user_agent: "Mozilla/5.0",
|
|
created_at: "2010-01-01 12:00:00 UTC",
|
|
)
|
|
exporter.extra["name"] = "suspicious_logins"
|
|
|
|
report = export_report
|
|
|
|
expect(report.second[0, 3]).to eq([user.username, "1.1.1.1", "Earth"])
|
|
end
|
|
|
|
context "when the current user cannot view IPs" do
|
|
fab!(:moderator_without_ip_access, :moderator)
|
|
|
|
let(:user) { moderator_without_ip_access }
|
|
|
|
it "redacts suspicious login IP address while retaining location" do
|
|
SiteSetting.moderators_view_ips = false
|
|
DiscourseIpInfo.stubs(:get).returns(location: "Earth")
|
|
|
|
moderator_without_ip_access.user_auth_token_logs.create!(
|
|
action: "suspicious",
|
|
client_ip: "1.1.1.1",
|
|
user_agent: "Mozilla/5.0",
|
|
created_at: "2010-01-01 12:00:00 UTC",
|
|
)
|
|
exporter.extra["name"] = "suspicious_logins"
|
|
|
|
report = export_report
|
|
|
|
expect(report.second[0]).to eq(moderator_without_ip_access.username)
|
|
expect(report.second[1]).to eq("")
|
|
expect(report.second[2]).to eq("Earth")
|
|
end
|
|
end
|
|
|
|
it "works with topic reports" do
|
|
freeze_time DateTime.parse("2010-01-01 6:00")
|
|
|
|
exporter.extra["name"] = "top_referred_topics"
|
|
post1 = Fabricate(:post)
|
|
Fabricate(:post)
|
|
IncomingLink.add(
|
|
host: "a.com",
|
|
referer: "http://twitter.com",
|
|
post_id: post1.id,
|
|
ip_address: "1.1.1.1",
|
|
)
|
|
|
|
report = export_report
|
|
|
|
expect(report.first).to contain_exactly("Topic", "Clicks")
|
|
expect(report.second).to contain_exactly(post1.topic.id.to_s, "1")
|
|
end
|
|
|
|
it "works with stacked_chart reports" do
|
|
ApplicationRequest.create!(date: "2010-01-01", req_type: "page_view_logged_in", count: 1)
|
|
ApplicationRequest.create!(date: "2010-01-02", req_type: "page_view_logged_in", count: 2)
|
|
ApplicationRequest.create!(date: "2010-01-03", req_type: "page_view_logged_in", count: 3)
|
|
|
|
ApplicationRequest.create!(date: "2010-01-01", req_type: "page_view_anon", count: 4)
|
|
ApplicationRequest.create!(date: "2010-01-02", req_type: "page_view_anon", count: 5)
|
|
ApplicationRequest.create!(date: "2010-01-03", req_type: "page_view_anon", count: 6)
|
|
|
|
ApplicationRequest.create!(date: "2010-01-01", req_type: "page_view_crawler", count: 7)
|
|
ApplicationRequest.create!(date: "2010-01-02", req_type: "page_view_crawler", count: 8)
|
|
ApplicationRequest.create!(date: "2010-01-03", req_type: "page_view_crawler", count: 9)
|
|
|
|
exporter.extra["name"] = "consolidated_page_views"
|
|
|
|
report = export_report
|
|
|
|
expect(report[0]).to contain_exactly("Day", "Logged in users", "Anonymous users", "Crawlers")
|
|
expect(report[1]).to contain_exactly("2010-01-01", "1", "4", "7")
|
|
expect(report[2]).to contain_exactly("2010-01-02", "2", "5", "8")
|
|
expect(report[3]).to contain_exactly("2010-01-03", "3", "6", "9")
|
|
end
|
|
|
|
it "works with stacked_chart reports and hidden_labels" do
|
|
ApplicationRequest.create!(date: "2010-01-01", req_type: "page_view_logged_in", count: 1)
|
|
ApplicationRequest.create!(date: "2010-01-01", req_type: "page_view_anon", count: 4)
|
|
ApplicationRequest.create!(date: "2010-01-01", req_type: "page_view_crawler", count: 7)
|
|
|
|
exporter.extra["name"] = "consolidated_page_views"
|
|
exporter.extra["hidden_labels"] = "page_view_crawler"
|
|
|
|
report = export_report
|
|
|
|
expect(report[0]).to contain_exactly("Day", "Logged in users", "Anonymous users")
|
|
expect(report[1]).to contain_exactly("2010-01-01", "1", "4")
|
|
end
|
|
|
|
it "works with posts reports and filters" do
|
|
category = Fabricate(:category)
|
|
subcategory = Fabricate(:category, parent_category: category)
|
|
|
|
Fabricate(
|
|
:post,
|
|
topic: Fabricate(:topic, category: category),
|
|
created_at: "2010-01-01 12:00:00 UTC",
|
|
)
|
|
Fabricate(
|
|
:post,
|
|
topic: Fabricate(:topic, category: subcategory),
|
|
created_at: "2010-01-01 12:00:00 UTC",
|
|
)
|
|
|
|
exporter.extra["name"] = "posts"
|
|
|
|
exporter.extra["category"] = category.id
|
|
|
|
report = export_report
|
|
|
|
expect(report[0]).to contain_exactly("Count", "Day")
|
|
expect(report[1]).to contain_exactly("1", "2010-01-01")
|
|
|
|
exporter.extra["include_subcategories"] = true
|
|
|
|
report = export_report
|
|
|
|
expect(report[0]).to contain_exactly("Count", "Day")
|
|
expect(report[1]).to contain_exactly("2", "2010-01-01")
|
|
end
|
|
|
|
def export_report
|
|
report = []
|
|
exporter.report_export { |entry| report << entry }
|
|
report
|
|
end
|
|
end
|
|
|
|
let(:user_list_header) do
|
|
%w[
|
|
id
|
|
name
|
|
username
|
|
email
|
|
title
|
|
created_at
|
|
last_seen_at
|
|
last_posted_at
|
|
last_emailed_at
|
|
trust_level
|
|
approved
|
|
suspended_at
|
|
suspended_till
|
|
blocked
|
|
active
|
|
admin
|
|
moderator
|
|
ip_address
|
|
staged
|
|
secondary_emails
|
|
topics_entered
|
|
posts_read_count
|
|
time_read
|
|
topic_count
|
|
post_count
|
|
likes_given
|
|
likes_received
|
|
location
|
|
website
|
|
views
|
|
]
|
|
end
|
|
|
|
let(:user_list_export) do
|
|
exported_data = []
|
|
Jobs::ExportCsvFile.new.user_list_export { |entry| exported_data << entry }
|
|
exported_data
|
|
end
|
|
|
|
def to_hash(row)
|
|
Hash[*user_list_header.zip(row).flatten]
|
|
end
|
|
|
|
def parse_staff_action_rows(upload)
|
|
rows = []
|
|
Zip::File.open(Discourse.store.path_for(upload)) do |zip_file|
|
|
zip_file.each do |entry|
|
|
csv_rows = CSV.parse(zip_file.read(entry), headers: true)
|
|
rows.concat(csv_rows.map(&:to_h))
|
|
end
|
|
end
|
|
rows
|
|
end
|
|
|
|
it "exports secondary emails" do
|
|
user = Fabricate(:user)
|
|
Fabricate(:secondary_email, user: user, primary: false)
|
|
secondary_emails = user.secondary_emails
|
|
|
|
user = to_hash(user_list_export.find { |u| u[0].to_i == user.id })
|
|
|
|
expect(user["secondary_emails"].split(";")).to match_array(secondary_emails)
|
|
end
|
|
|
|
it "exports sso data" do
|
|
SiteSetting.discourse_connect_url = "https://www.example.com/sso"
|
|
SiteSetting.discourse_connect_secret = "x" * 10
|
|
SiteSetting.enable_discourse_connect = true
|
|
user = Fabricate(:user)
|
|
user.user_profile.update_column(:location, "La,La Land")
|
|
user.create_single_sign_on_record(
|
|
external_id: "123",
|
|
last_payload: "xxx",
|
|
external_email: "test@test.com",
|
|
)
|
|
|
|
user_list_header.push(
|
|
"external_id",
|
|
"external_email",
|
|
"external_username",
|
|
"external_name",
|
|
"external_avatar_url",
|
|
)
|
|
|
|
user = to_hash(user_list_export.find { |u| u[0].to_i == user.id })
|
|
|
|
expect(user["location"]).to eq('"La,La Land"')
|
|
expect(user["external_id"]).to eq("123")
|
|
expect(user["external_email"]).to eq("test@test.com")
|
|
end
|
|
|
|
it "exports user fields" do
|
|
user_field_1 = Fabricate(:user_field, name: "custom field 1")
|
|
user_field_2 = Fabricate(:user_field, name: "custom field 2", field_type: "confirm")
|
|
user_field_3 = Fabricate(:user_field, name: "custom field 3", field_type: "confirm")
|
|
|
|
user = Fabricate(:user)
|
|
user.set_user_field(user_field_1.id, "Answer custom 1")
|
|
user.set_user_field(user_field_2.id, true)
|
|
user.set_user_field(user_field_3.id, false)
|
|
user.save!
|
|
|
|
user_list_header.push(
|
|
"custom field 1 (custom user field)",
|
|
"custom field 2 (custom user field)",
|
|
"custom field 3 (custom user field)",
|
|
)
|
|
|
|
export_user = to_hash(user_list_export.find { |u| u[0].to_i == user.id })
|
|
|
|
expect(export_user["custom field 1 (custom user field)"]).to eq("Answer custom 1")
|
|
expect(export_user["custom field 2 (custom user field)"]).to eq("true")
|
|
expect(export_user["custom field 3 (custom user field)"]).to eq("false")
|
|
end
|
|
end
|