mirror of
https://github.com/discourse/discourse.git
synced 2026-08-07 11:45:21 -05:00
FEATURE: Add date range selector to staff action log (#33959)
This commit implements a date-time range selector to staff action log in admin page, making it easier to filter and analyze the data. Additionally, it allows to apply the date-time picker to the exported file as well, reducing the exported file size.
This commit is contained in:
@@ -13,10 +13,14 @@ export default class AdminLogsStaffActionLogsController extends Controller {
|
||||
@service modal;
|
||||
@service store;
|
||||
|
||||
queryParams = ["filters"];
|
||||
queryParams = ["filters", "startDate", "endDate"];
|
||||
model = null;
|
||||
filters = null;
|
||||
userHistoryActions = null;
|
||||
/** @type {moment.Moment | null} */
|
||||
startDate = null;
|
||||
/** @type {moment.Moment | null} */
|
||||
endDate = null;
|
||||
|
||||
@discourseComputed("filters.action_name")
|
||||
actionFilter(name) {
|
||||
@@ -29,25 +33,31 @@ export default class AdminLogsStaffActionLogsController extends Controller {
|
||||
}
|
||||
|
||||
_refresh() {
|
||||
this.store.findAll("staff-action-log", this.filters).then((result) => {
|
||||
this.set("model", result);
|
||||
this.store
|
||||
.findAll("staff-action-log", {
|
||||
...this.filters,
|
||||
start_date: this.startDate?.toISOString(),
|
||||
end_date: this.endDate?.toISOString(),
|
||||
})
|
||||
.then((result) => {
|
||||
this.set("model", result);
|
||||
|
||||
if (!this.userHistoryActions) {
|
||||
this.set(
|
||||
"userHistoryActions",
|
||||
result.extras.user_history_actions
|
||||
.map((historyAction) => ({
|
||||
id: historyAction.id,
|
||||
action_id: historyAction.action_id,
|
||||
name: i18n(
|
||||
"admin.logs.staff_actions.actions." + historyAction.id
|
||||
),
|
||||
name_raw: historyAction.id,
|
||||
}))
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
);
|
||||
}
|
||||
});
|
||||
if (!this.userHistoryActions) {
|
||||
this.set(
|
||||
"userHistoryActions",
|
||||
result.extras.user_history_actions
|
||||
.map((historyAction) => ({
|
||||
id: historyAction.id,
|
||||
action_id: historyAction.action_id,
|
||||
name: i18n(
|
||||
"admin.logs.staff_actions.actions." + historyAction.id
|
||||
),
|
||||
name_raw: historyAction.id,
|
||||
}))
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
scheduleRefresh() {
|
||||
@@ -145,7 +155,10 @@ export default class AdminLogsStaffActionLogsController extends Controller {
|
||||
|
||||
@action
|
||||
exportStaffActionLogs() {
|
||||
exportEntity("staff_action").then(outputExportResult);
|
||||
exportEntity("staff_action", {
|
||||
start_date: this.startDate?.toISOString(),
|
||||
end_date: this.endDate?.toISOString(),
|
||||
}).then(outputExportResult);
|
||||
}
|
||||
|
||||
@action
|
||||
@@ -168,4 +181,16 @@ export default class AdminLogsStaffActionLogsController extends Controller {
|
||||
model: { staffActionLog: model },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @arg {moment.Moment} from
|
||||
* @arg {moment.Moment} to
|
||||
*/
|
||||
@action
|
||||
onChangeDateRange({ from, to }) {
|
||||
this.set("startDate", from);
|
||||
this.set("endDate", to);
|
||||
this.set("model", EmberObject.create({ loadingMore: true }));
|
||||
this.scheduleRefresh();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ export default class AdminLogsStaffActionLogsRoute extends DiscourseRoute {
|
||||
|
||||
queryParams = {
|
||||
filters: { refreshModel: true },
|
||||
startDate: { refreshModel: true },
|
||||
endDate: { refreshModel: true },
|
||||
};
|
||||
|
||||
beforeModel(transition) {
|
||||
@@ -21,6 +23,9 @@ export default class AdminLogsStaffActionLogsRoute extends DiscourseRoute {
|
||||
if (urlKey === "filters" && value) {
|
||||
return EmberObject.create(JSON.parse(decodeURIComponent(value)));
|
||||
}
|
||||
if (urlKey === "startDate" || urlKey === "endDate") {
|
||||
return value ? moment(value) : null;
|
||||
}
|
||||
|
||||
return super.deserializeQueryParam(value, urlKey, defaultValueType);
|
||||
}
|
||||
@@ -33,6 +38,9 @@ export default class AdminLogsStaffActionLogsRoute extends DiscourseRoute {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
if (urlKey === "startDate" || urlKey === "endDate") {
|
||||
return value ? value.toISOString() : null;
|
||||
}
|
||||
|
||||
return super.serializeQueryParam(value, urlKey, defaultValueType);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { htmlSafe } from "@ember/template";
|
||||
import RouteTemplate from "ember-route-template";
|
||||
import ConditionalLoadingSpinner from "discourse/components/conditional-loading-spinner";
|
||||
import DButton from "discourse/components/d-button";
|
||||
import DateTimeInputRange from "discourse/components/date-time-input-range";
|
||||
import LoadMore from "discourse/components/load-more";
|
||||
import ageWithTooltip from "discourse/helpers/age-with-tooltip";
|
||||
import avatar from "discourse/helpers/avatar";
|
||||
@@ -16,85 +17,99 @@ import ComboBox from "select-kit/components/combo-box";
|
||||
export default RouteTemplate(
|
||||
<template>
|
||||
<div class="staff-action-logs-controls">
|
||||
{{#if @controller.filtersExists}}
|
||||
<div class="staff-action-logs-filters">
|
||||
<a
|
||||
href
|
||||
{{on "click" @controller.clearAllFilters}}
|
||||
class="clear-filters filter btn"
|
||||
>
|
||||
<span class="label">{{i18n
|
||||
"admin.logs.staff_actions.clear_filters"
|
||||
}}</span>
|
||||
</a>
|
||||
{{#if @controller.actionFilter}}
|
||||
<div class="staff-action-logs-controls__left">
|
||||
{{#if @controller.filtersExists}}
|
||||
<div class="staff-action-logs-filters">
|
||||
<a
|
||||
href
|
||||
{{on "click" (fn @controller.clearFilter "actionFilter")}}
|
||||
class="filter btn"
|
||||
>
|
||||
<span class="label">{{i18n "admin.logs.action"}}</span>:
|
||||
{{@controller.actionFilter}}
|
||||
{{icon "circle-xmark"}}
|
||||
</a>
|
||||
{{/if}}
|
||||
{{#if @controller.filters.acting_user}}
|
||||
<a
|
||||
href
|
||||
{{on "click" (fn @controller.clearFilter "acting_user")}}
|
||||
class="filter btn"
|
||||
{{on "click" @controller.clearAllFilters}}
|
||||
class="clear-filters filter btn"
|
||||
>
|
||||
<span class="label">{{i18n
|
||||
"admin.logs.staff_actions.staff_user"
|
||||
}}</span>:
|
||||
{{@controller.filters.acting_user}}
|
||||
{{icon "circle-xmark"}}
|
||||
"admin.logs.staff_actions.clear_filters"
|
||||
}}</span>
|
||||
</a>
|
||||
{{/if}}
|
||||
{{#if @controller.filters.target_user}}
|
||||
<a
|
||||
href
|
||||
{{on "click" (fn @controller.clearFilter "target_user")}}
|
||||
class="filter btn"
|
||||
>
|
||||
<span class="label">{{i18n
|
||||
"admin.logs.staff_actions.target_user"
|
||||
}}</span>:
|
||||
{{@controller.filters.target_user}}
|
||||
{{icon "circle-xmark"}}
|
||||
</a>
|
||||
{{/if}}
|
||||
{{#if @controller.filters.subject}}
|
||||
<a
|
||||
href
|
||||
{{on "click" (fn @controller.clearFilter "subject")}}
|
||||
class="filter btn"
|
||||
>
|
||||
<span class="label">{{i18n
|
||||
"admin.logs.staff_actions.subject"
|
||||
}}</span>:
|
||||
{{@controller.filters.subject}}
|
||||
{{icon "circle-xmark"}}
|
||||
</a>
|
||||
{{/if}}
|
||||
</div>
|
||||
{{else}}
|
||||
{{i18n "admin.logs.staff_actions.filter"}}
|
||||
<ComboBox
|
||||
@content={{@controller.userHistoryActions}}
|
||||
@value={{@controller.filterActionId}}
|
||||
@onChange={{@controller.filterActionIdChanged}}
|
||||
@options={{hash none="admin.logs.staff_actions.all"}}
|
||||
@id="staff-action-logs-action-filter"
|
||||
/>
|
||||
{{/if}}
|
||||
{{#if @controller.actionFilter}}
|
||||
<a
|
||||
href
|
||||
{{on "click" (fn @controller.clearFilter "actionFilter")}}
|
||||
class="filter btn"
|
||||
>
|
||||
<span class="label">{{i18n "admin.logs.action"}}</span>:
|
||||
{{@controller.actionFilter}}
|
||||
{{icon "circle-xmark"}}
|
||||
</a>
|
||||
{{/if}}
|
||||
{{#if @controller.filters.acting_user}}
|
||||
<a
|
||||
href
|
||||
{{on "click" (fn @controller.clearFilter "acting_user")}}
|
||||
class="filter btn"
|
||||
>
|
||||
<span class="label">{{i18n
|
||||
"admin.logs.staff_actions.staff_user"
|
||||
}}</span>:
|
||||
{{@controller.filters.acting_user}}
|
||||
{{icon "circle-xmark"}}
|
||||
</a>
|
||||
{{/if}}
|
||||
{{#if @controller.filters.target_user}}
|
||||
<a
|
||||
href
|
||||
{{on "click" (fn @controller.clearFilter "target_user")}}
|
||||
class="filter btn"
|
||||
>
|
||||
<span class="label">{{i18n
|
||||
"admin.logs.staff_actions.target_user"
|
||||
}}</span>:
|
||||
{{@controller.filters.target_user}}
|
||||
{{icon "circle-xmark"}}
|
||||
</a>
|
||||
{{/if}}
|
||||
{{#if @controller.filters.subject}}
|
||||
<a
|
||||
href
|
||||
{{on "click" (fn @controller.clearFilter "subject")}}
|
||||
class="filter btn"
|
||||
>
|
||||
<span class="label">{{i18n
|
||||
"admin.logs.staff_actions.subject"
|
||||
}}</span>:
|
||||
{{@controller.filters.subject}}
|
||||
{{icon "circle-xmark"}}
|
||||
</a>
|
||||
{{/if}}
|
||||
</div>
|
||||
{{else}}
|
||||
{{i18n "admin.logs.staff_actions.filter"}}
|
||||
<ComboBox
|
||||
@content={{@controller.userHistoryActions}}
|
||||
@value={{@controller.filterActionId}}
|
||||
@onChange={{@controller.filterActionIdChanged}}
|
||||
@options={{hash none="admin.logs.staff_actions.all"}}
|
||||
@id="staff-action-logs-action-filter"
|
||||
/>
|
||||
{{/if}}
|
||||
|
||||
<DButton
|
||||
@action={{@controller.exportStaffActionLogs}}
|
||||
@label="admin.export_csv.button_text"
|
||||
@icon="download"
|
||||
class="btn-default"
|
||||
/>
|
||||
<div class="date-filter-container">
|
||||
<DateTimeInputRange
|
||||
@from={{@controller.startDate}}
|
||||
@to={{@controller.endDate}}
|
||||
@onChange={{@controller.onChangeDateRange}}
|
||||
@showFromTime={{false}}
|
||||
@showToTime={{false}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="staff-action-logs-controls__right">
|
||||
<DButton
|
||||
@action={{@controller.exportStaffActionLogs}}
|
||||
@label="admin.export_csv.button_text"
|
||||
@icon="download"
|
||||
class="btn-default export-staff-action-logs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="clearfix"></div>
|
||||
|
||||
@@ -138,15 +138,47 @@
|
||||
|
||||
.staff-action-logs-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-items: baseline;
|
||||
gap: 0.5em;
|
||||
justify-content: space-between;
|
||||
margin: 0 0 1em 0;
|
||||
|
||||
.select-kit {
|
||||
margin: 0 0.5em;
|
||||
// for too small screen, we want it to also wrap
|
||||
@media (width <= 400px) {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
button {
|
||||
margin-left: auto;
|
||||
.staff-action-logs-controls__left {
|
||||
flex: 1 1 auto;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5em;
|
||||
|
||||
// we want to put it in one line
|
||||
.date-filter-container .d-date-time-input-range {
|
||||
display: flex;
|
||||
gap: 0.5em;
|
||||
|
||||
.from {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#staff-action-logs-action-filter {
|
||||
// unset the global width of .admin-container .select-kit, so we can shrink it by flex
|
||||
width: unset;
|
||||
flex: 1 0 120px;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.action-filter-container {
|
||||
flex: 0 1 380px;
|
||||
display: flex;
|
||||
gap: 0.5em;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
a.filter {
|
||||
|
||||
@@ -4,12 +4,13 @@ class Admin::StaffActionLogsController < Admin::StaffController
|
||||
INDEX_LIMIT = 200
|
||||
|
||||
def index
|
||||
filters = params.slice(*UserHistory.staff_filters + %i[page limit])
|
||||
filters = params.slice(*UserHistory.staff_filters + %i[page limit], :start_date, :end_date)
|
||||
|
||||
page = (params[:page] || 0).to_i
|
||||
page_size = fetch_limit_from_params(default: INDEX_LIMIT, max: INDEX_LIMIT)
|
||||
|
||||
staff_action_logs = UserHistory.staff_action_records(current_user, filters)
|
||||
|
||||
count = staff_action_logs.count
|
||||
staff_action_logs = staff_action_logs.offset(page * page_size).limit(page_size).to_a
|
||||
|
||||
|
||||
@@ -148,11 +148,18 @@ module Jobs
|
||||
end
|
||||
|
||||
def staff_action_export
|
||||
start_date = @extra[:start_date]&.to_time if @extra
|
||||
end_date = @extra[:end_date]&.to_time if @extra
|
||||
|
||||
query = UserHistory
|
||||
query = query.where("created_at >= ?", start_date) if start_date.present?
|
||||
query = query.where("created_at <= ?", end_date) if end_date.present?
|
||||
|
||||
staff_action_data =
|
||||
if @current_user.admin?
|
||||
UserHistory.only_staff_actions
|
||||
query.only_staff_actions
|
||||
else
|
||||
UserHistory.where(admin_only: false).only_staff_actions
|
||||
query.where(admin_only: false).only_staff_actions
|
||||
end
|
||||
|
||||
staff_action_data.find_each(order: :desc) do |staff_action|
|
||||
|
||||
@@ -337,6 +337,10 @@ class UserHistory < ActiveRecord::Base
|
||||
.order("id DESC")
|
||||
.includes(:acting_user, :target_user)
|
||||
query = query.where(admin_only: false) unless viewer && viewer.admin?
|
||||
|
||||
query = query.where("created_at >= ?", opts[:start_date].to_time) if opts[:start_date]
|
||||
query = query.where("created_at <= ?", opts[:end_date].to_time) if opts[:end_date]
|
||||
|
||||
query
|
||||
end
|
||||
|
||||
|
||||
@@ -7324,6 +7324,7 @@ en:
|
||||
modal_title: "Details"
|
||||
no_previous: "There is no previous value."
|
||||
deleted: "No new value. The record was deleted."
|
||||
filter_date_time: "Filter date and time"
|
||||
actions:
|
||||
permanently_delete_post_revisions: "permanently delete post revisions"
|
||||
delete_user: "delete user"
|
||||
|
||||
@@ -72,6 +72,40 @@ RSpec.describe Jobs::ExportCsvFile do
|
||||
admin.uploads.each(&:destroy!)
|
||||
end
|
||||
end
|
||||
|
||||
context "when exporting staff action" do
|
||||
before do
|
||||
freeze_time
|
||||
(1..10).each do |i|
|
||||
Fabricate(
|
||||
:user_history,
|
||||
action: UserHistory.actions[:suspend_user],
|
||||
created_at: i.days.ago,
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
it "exports staff action logs with date filters" do
|
||||
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.minutes).iso8601,
|
||||
end_date: (2.days.ago + 1.minutes).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
|
||||
end
|
||||
end
|
||||
|
||||
describe ".report_export" do
|
||||
|
||||
@@ -77,6 +77,23 @@ RSpec.describe UserHistory do
|
||||
expect(records.size).to eq(1)
|
||||
expect(records.first).to eq(custom_history)
|
||||
end
|
||||
|
||||
it "filters by start and/or end date" do
|
||||
freeze_time
|
||||
|
||||
10.times do |i|
|
||||
Fabricate(
|
||||
:user_history,
|
||||
action: UserHistory.actions[:suspend_user],
|
||||
created_at: i.days.ago,
|
||||
)
|
||||
end
|
||||
|
||||
records =
|
||||
described_class.staff_action_records(admin, start_date: 7.days.ago, end_date: 2.days.ago)
|
||||
|
||||
expect(records.size).to eq(7 - 2 + 1)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -28,6 +28,57 @@ RSpec.describe Admin::StaffActionLogsController do
|
||||
)
|
||||
end
|
||||
|
||||
describe "filter logs by date" do
|
||||
before do
|
||||
freeze_time
|
||||
topic = Fabricate(:topic)
|
||||
StaffActionLogger.new(Discourse.system_user).log_topic_delete_recover(
|
||||
topic,
|
||||
"delete_topic",
|
||||
)
|
||||
freeze_time 3.days.from_now
|
||||
StaffActionLogger.new(Discourse.system_user).log_grant_admin(user)
|
||||
freeze_time 2.days.from_now
|
||||
StaffActionLogger.new(Discourse.system_user).log_user_suspend(user, "reason")
|
||||
end
|
||||
|
||||
it "filter logs by start_date" do
|
||||
get "/admin/logs/staff_action_logs.json", params: { start_date: 3.days.ago.iso8601 }
|
||||
|
||||
json = response.parsed_body
|
||||
expect(response.status).to eq(200)
|
||||
|
||||
expect(json["staff_action_logs"].length).to eq(2)
|
||||
expect(json["staff_action_logs"][0]["action_name"]).to eq("suspend_user")
|
||||
expect(json["staff_action_logs"][1]["action_name"]).to eq("grant_admin")
|
||||
end
|
||||
|
||||
it "filter logs by end_date" do
|
||||
get "/admin/logs/staff_action_logs.json", params: { end_date: 1.days.ago.iso8601 }
|
||||
|
||||
json = response.parsed_body
|
||||
expect(response.status).to eq(200)
|
||||
|
||||
expect(json["staff_action_logs"].length).to eq(2)
|
||||
expect(json["staff_action_logs"][0]["action_name"]).to eq("grant_admin")
|
||||
expect(json["staff_action_logs"][1]["action_name"]).to eq("delete_topic")
|
||||
end
|
||||
|
||||
it "filter logs by start_date and end_date" do
|
||||
get "/admin/logs/staff_action_logs.json",
|
||||
params: {
|
||||
start_date: 3.days.ago.iso8601,
|
||||
end_date: 1.days.ago.iso8601,
|
||||
}
|
||||
|
||||
json = response.parsed_body
|
||||
expect(response.status).to eq(200)
|
||||
|
||||
expect(json["staff_action_logs"].length).to eq(1)
|
||||
expect(json["staff_action_logs"][0]["action_name"]).to eq("grant_admin")
|
||||
end
|
||||
end
|
||||
|
||||
describe "when limit params is invalid" do
|
||||
include_examples "invalid limit params",
|
||||
"/admin/logs/staff_action_logs.json",
|
||||
|
||||
@@ -80,4 +80,42 @@ describe "Admin staff action logs", type: :system do
|
||||
find("#{staff_action_logs_page.log_row_selector(history_1)} .col.value.details a").click
|
||||
expect(PageObjects::Modals::Base.new).to have_content(history_1.details)
|
||||
end
|
||||
|
||||
describe "date time filting" do
|
||||
let!(:histories) do
|
||||
(1..10).map do |i|
|
||||
Fabricate(:user_history, action: UserHistory.actions[:suspend_user], created_at: i.days.ago)
|
||||
end
|
||||
end
|
||||
|
||||
it "can see filtered logs" do
|
||||
visit "/admin/logs/staff_action_logs"
|
||||
|
||||
staff_action_logs_page.fill_date_filter_from(7.days.ago)
|
||||
staff_action_logs_page.fill_date_filter_to(2.days.ago)
|
||||
|
||||
[1, 2, 8, 9, 10].each do |i|
|
||||
expect(staff_action_logs_page).to have_no_log_row(histories[i - 1])
|
||||
end
|
||||
[3, 4, 5, 6, 7].each { |i| expect(staff_action_logs_page).to have_log_row(histories[i - 1]) }
|
||||
end
|
||||
|
||||
it "can export filtered logs" do
|
||||
visit "/admin/logs/staff_action_logs"
|
||||
|
||||
staff_action_logs_page.fill_date_filter_from(7.days.ago)
|
||||
staff_action_logs_page.fill_date_filter_to(2.days.ago)
|
||||
|
||||
expect do
|
||||
staff_action_logs_page.click_export_button
|
||||
|
||||
expect(page).to have_text(I18n.t("admin_js.admin.export_csv.success"))
|
||||
end.to change { Jobs::ExportCsvFile.jobs.size }.by(1)
|
||||
|
||||
job = Jobs::ExportCsvFile.jobs.last
|
||||
args = job["args"].first["args"]
|
||||
expect(args).to include("start_date")
|
||||
expect(args).to include("end_date")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -33,6 +33,18 @@ module PageObjects
|
||||
def clear_filter
|
||||
find(".clear-filters").click
|
||||
end
|
||||
|
||||
def click_export_button
|
||||
find(".export-staff-action-logs").click
|
||||
end
|
||||
|
||||
def fill_date_filter_from(date)
|
||||
find(".d-date-time-input.from .d-date-input input").set(date)
|
||||
end
|
||||
|
||||
def fill_date_filter_to(date)
|
||||
find(".d-date-time-input.to .d-date-input input").set(date)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
Reference in New Issue
Block a user