FEATURE: Allow data explorer query result to be sent as recurring PM (#233)
This feature enables admins to create reports automatically based on a recurring schedule. It introduces a new automation script that includes the new email_group_user field added to discourse-automation, along with a query_id and query_params to pass in parameters to the existing data explorer query. The output of the report will be sent via pm (as a markdown table) to the recipients entered within the automation script. The automation (supports individual users, email addresses and groups).
This commit is contained in:
parent
206d937a78
commit
705753216c
|
@ -0,0 +1,7 @@
|
||||||
|
{
|
||||||
|
"tests": {
|
||||||
|
"requiredPlugins": [
|
||||||
|
"https://github.com/discourse/discourse-automation"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
|
@ -96,3 +96,14 @@ en:
|
||||||
descriptions:
|
descriptions:
|
||||||
discourse_data_explorer:
|
discourse_data_explorer:
|
||||||
run_queries: "Run Data Explorer queries. Restrict the API key to a set of queries by specifying queries IDs."
|
run_queries: "Run Data Explorer queries. Restrict the API key to a set of queries by specifying queries IDs."
|
||||||
|
discourse_automation:
|
||||||
|
scriptables:
|
||||||
|
recurring_data_explorer_result_pm:
|
||||||
|
fields:
|
||||||
|
recipients:
|
||||||
|
label: Send to User, Group or Email
|
||||||
|
query_id:
|
||||||
|
label: Data Explorer Query
|
||||||
|
query_params:
|
||||||
|
label: Data Explorer Query parameters
|
||||||
|
|
||||||
|
|
|
@ -1,3 +1,8 @@
|
||||||
en:
|
en:
|
||||||
site_settings:
|
site_settings:
|
||||||
data_explorer_enabled: "Enable the Data Explorer at /admin/plugins/explorer"
|
data_explorer_enabled: "Enable the Data Explorer at /admin/plugins/explorer"
|
||||||
|
discourse_automation:
|
||||||
|
scriptables:
|
||||||
|
recurring_data_explorer_result_pm:
|
||||||
|
title: Schedule a PM with Data Explorer results
|
||||||
|
description: Get scheduled reports sent to your messages each month
|
||||||
|
|
|
@ -0,0 +1,76 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module ::DiscourseDataExplorer
|
||||||
|
class ReportGenerator
|
||||||
|
def initialize(creator_user_id)
|
||||||
|
@creator_user_id = creator_user_id
|
||||||
|
end
|
||||||
|
|
||||||
|
def generate(query_id, query_params, recipients)
|
||||||
|
query = DiscourseDataExplorer::Query.find(query_id)
|
||||||
|
return [] unless query
|
||||||
|
|
||||||
|
usernames = filter_recipients_by_query_access(recipients, query)
|
||||||
|
return [] if usernames.empty?
|
||||||
|
params = params_to_hash(query_params)
|
||||||
|
|
||||||
|
result = DataExplorer.run_query(query, params)
|
||||||
|
query.update!(last_run_at: Time.now)
|
||||||
|
|
||||||
|
table = ResultToMarkdown.convert(result[:pg_result])
|
||||||
|
|
||||||
|
build_report_pms(query, table, usernames)
|
||||||
|
end
|
||||||
|
|
||||||
|
def filter_recipients_by_query_access(recipients, query)
|
||||||
|
return [] if recipients.empty?
|
||||||
|
creator = User.find(@creator_user_id)
|
||||||
|
return [] unless Guardian.new(creator).can_send_private_messages?
|
||||||
|
|
||||||
|
recipients.reduce([]) do |names, recipient|
|
||||||
|
if (group = Group.find_by(name: recipient))
|
||||||
|
next names unless query.query_groups.exists?(group_id: group.id)
|
||||||
|
next names.concat group.users.pluck(:username)
|
||||||
|
elsif (user = User.find_by(username: recipient))
|
||||||
|
next names unless Guardian.new(user).user_can_access_query?(query)
|
||||||
|
next names << recipient
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def params_to_hash(query_params)
|
||||||
|
params = JSON.parse(query_params)
|
||||||
|
params_hash = {}
|
||||||
|
|
||||||
|
if !params.blank?
|
||||||
|
param_key, param_value = [], []
|
||||||
|
params.flatten.each.with_index do |data, i|
|
||||||
|
if i % 2 == 0
|
||||||
|
param_key << data
|
||||||
|
else
|
||||||
|
param_value << data
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
params_hash = Hash[param_key.zip(param_value)]
|
||||||
|
end
|
||||||
|
|
||||||
|
params_hash
|
||||||
|
end
|
||||||
|
|
||||||
|
def build_report_pms(query, table = "", usernames = [])
|
||||||
|
pms = []
|
||||||
|
usernames.flatten.compact.uniq.each do |username|
|
||||||
|
pm = {}
|
||||||
|
pm["title"] = "Scheduled Report for #{query.name}"
|
||||||
|
pm["target_usernames"] = Array(username)
|
||||||
|
pm["raw"] = "Hi #{username}, your data explorer report is ready.\n\n" +
|
||||||
|
"Query Name:\n#{query.name}\n\nHere are the results:\n#{table}\n\n" +
|
||||||
|
"<a href='/admin/plugins/explorer?id=#{query.id}'>View query in Data Explorer</a>\n\n" +
|
||||||
|
"Report created at #{Time.zone.now.strftime("%Y-%m-%d at %H:%M:%S")} (#{Time.zone.name})"
|
||||||
|
pms << pm
|
||||||
|
end
|
||||||
|
pms
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
|
@ -0,0 +1,49 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
include HasSanitizableFields
|
||||||
|
|
||||||
|
module ::DiscourseDataExplorer
|
||||||
|
class ResultToMarkdown
|
||||||
|
def self.convert(pg_result)
|
||||||
|
relations, colrender = DataExplorer.add_extra_data(pg_result)
|
||||||
|
result_data = []
|
||||||
|
|
||||||
|
# column names to search in place of id columns (topic_id, user_id etc)
|
||||||
|
cols = %w[name title username]
|
||||||
|
|
||||||
|
# find values from extra data, based on result id
|
||||||
|
pg_result.values.each do |row|
|
||||||
|
row_data = []
|
||||||
|
|
||||||
|
row.each_with_index do |col, col_index|
|
||||||
|
col_name = pg_result.fields[col_index]
|
||||||
|
related = relations.dig(colrender[col_index].to_sym) if col_index < colrender.size
|
||||||
|
|
||||||
|
if related.is_a?(ActiveModel::ArraySerializer)
|
||||||
|
related_row = related.object.find_by(id: col)
|
||||||
|
if col_name.include?("_id")
|
||||||
|
column = cols.find { |c| related_row.try c }
|
||||||
|
else
|
||||||
|
column = related_row.try(col_name)
|
||||||
|
end
|
||||||
|
|
||||||
|
if column.nil?
|
||||||
|
row_data[col_index] = col
|
||||||
|
else
|
||||||
|
row_data[col_index] = related_row[column]
|
||||||
|
end
|
||||||
|
else
|
||||||
|
row_data[col_index] = col
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
result_data << row_data.map { |c| "| #{sanitize_field(c.to_s)} " }.join + "|\n"
|
||||||
|
end
|
||||||
|
|
||||||
|
table_headers = pg_result.fields.map { |c| " #{c.gsub("_id", "")} |" }.join
|
||||||
|
table_body = pg_result.fields.size.times.map { " :-----: |" }.join
|
||||||
|
|
||||||
|
"|#{table_headers}\n|#{table_body}\n#{result_data.join}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
45
plugin.rb
45
plugin.rb
|
@ -70,4 +70,49 @@ after_initialize do
|
||||||
:discourse_data_explorer,
|
:discourse_data_explorer,
|
||||||
{ run_queries: { actions: %w[discourse_data_explorer/query#run], params: %i[id] } },
|
{ run_queries: { actions: %w[discourse_data_explorer/query#run], params: %i[id] } },
|
||||||
)
|
)
|
||||||
|
|
||||||
|
require_relative "lib/report_generator"
|
||||||
|
require_relative "lib/result_to_markdown"
|
||||||
|
reloadable_patch do
|
||||||
|
if defined?(DiscourseAutomation)
|
||||||
|
DiscourseAutomation::Scriptable::RECURRING_DATA_EXPLORER_RESULT_PM =
|
||||||
|
"recurring_data_explorer_result_pm"
|
||||||
|
add_automation_scriptable(
|
||||||
|
DiscourseAutomation::Scriptable::RECURRING_DATA_EXPLORER_RESULT_PM,
|
||||||
|
) do
|
||||||
|
queries =
|
||||||
|
DiscourseDataExplorer::Query
|
||||||
|
.where(hidden: false)
|
||||||
|
.map { |q| { id: q.id, translated_name: q.name } }
|
||||||
|
field :recipients, component: :email_group_user, required: true
|
||||||
|
field :query_id, component: :choices, required: true, extra: { content: queries }
|
||||||
|
field :query_params, component: :"key-value", accepts_placeholders: true
|
||||||
|
|
||||||
|
version 1
|
||||||
|
triggerables [:recurring]
|
||||||
|
|
||||||
|
script do |_, fields, automation|
|
||||||
|
recipients = Array(fields.dig("recipients", "value"))
|
||||||
|
query_id = fields.dig("query_id", "value")
|
||||||
|
query_params = fields.dig("query_params", "value")
|
||||||
|
|
||||||
|
unless SiteSetting.data_explorer_enabled
|
||||||
|
Rails.logger.warn "#{DiscourseDataExplorer.plugin_name} - plugin must be enabled to run automation #{automation.id}"
|
||||||
|
next
|
||||||
|
end
|
||||||
|
|
||||||
|
unless recipients.present?
|
||||||
|
Rails.logger.warn "#{DiscourseDataExplorer.plugin_name} - couldn't find any recipients for automation #{automation.id}"
|
||||||
|
next
|
||||||
|
end
|
||||||
|
|
||||||
|
data_explorer_report =
|
||||||
|
DiscourseDataExplorer::ReportGenerator.new(automation.last_updated_by_id)
|
||||||
|
report_pms = data_explorer_report.generate(query_id, query_params, recipients)
|
||||||
|
|
||||||
|
report_pms.each { |pm| utils.send_pm(pm, automation_id: automation.id) }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -0,0 +1,89 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require "rails_helper"
|
||||||
|
|
||||||
|
describe "RecurringDataExplorerResultPm" do
|
||||||
|
fab!(:admin) { Fabricate(:admin) }
|
||||||
|
|
||||||
|
fab!(:user) { Fabricate(:user) }
|
||||||
|
fab!(:another_user) { Fabricate(:user) }
|
||||||
|
fab!(:group_user) { Fabricate(:user) }
|
||||||
|
fab!(:not_allowed_user) { Fabricate(:user) }
|
||||||
|
|
||||||
|
fab!(:group) { Fabricate(:group, users: [user, another_user]) }
|
||||||
|
fab!(:another_group) { Fabricate(:group, users: [group_user]) }
|
||||||
|
|
||||||
|
fab!(:automation) do
|
||||||
|
Fabricate(
|
||||||
|
:automation,
|
||||||
|
script: DiscourseAutomation::Scriptable::RECURRING_DATA_EXPLORER_RESULT_PM,
|
||||||
|
trigger: "recurring",
|
||||||
|
)
|
||||||
|
end
|
||||||
|
fab!(:query) { Fabricate(:query) }
|
||||||
|
fab!(:query_group) { Fabricate(:query_group, query: query, group: group) }
|
||||||
|
fab!(:query_group) { Fabricate(:query_group, query: query, group: another_group) }
|
||||||
|
|
||||||
|
let!(:recipients) do
|
||||||
|
[user.username, not_allowed_user.username, another_user.username, another_group.name]
|
||||||
|
end
|
||||||
|
|
||||||
|
before do
|
||||||
|
SiteSetting.data_explorer_enabled = true
|
||||||
|
SiteSetting.discourse_automation_enabled = true
|
||||||
|
|
||||||
|
automation.upsert_field!("query_id", "choices", { value: query.id })
|
||||||
|
automation.upsert_field!("recipients", "email_group_user", { value: recipients })
|
||||||
|
automation.upsert_field!(
|
||||||
|
"query_params",
|
||||||
|
"key-value",
|
||||||
|
{ value: [%w[from_days_ago 0], %w[duration_days 15]] },
|
||||||
|
)
|
||||||
|
automation.upsert_field!(
|
||||||
|
"recurrence",
|
||||||
|
"period",
|
||||||
|
{ value: { interval: 1, frequency: "day" } },
|
||||||
|
target: "trigger",
|
||||||
|
)
|
||||||
|
automation.upsert_field!("start_date", "date_time", { value: 2.minutes.ago }, target: "trigger")
|
||||||
|
end
|
||||||
|
|
||||||
|
context "when using recurring trigger" do
|
||||||
|
it "sends the pm at recurring date_date" do
|
||||||
|
freeze_time 1.day.from_now do
|
||||||
|
expect { Jobs::DiscourseAutomationTracker.new.execute }.to change { Topic.count }.by(3)
|
||||||
|
|
||||||
|
title = "Scheduled Report for #{query.name}"
|
||||||
|
expect(Topic.last(3).pluck(:title)).to eq([title, title, title])
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
it "ensures only allowed users in recipients field receive reports via pm" do
|
||||||
|
expect do
|
||||||
|
automation.update(last_updated_by_id: admin.id)
|
||||||
|
automation.trigger!
|
||||||
|
end.to change { Topic.count }.by(3)
|
||||||
|
|
||||||
|
created_topics = Topic.last(3)
|
||||||
|
expect(created_topics.pluck(:archetype)).to eq(
|
||||||
|
[Archetype.private_message, Archetype.private_message, Archetype.private_message],
|
||||||
|
)
|
||||||
|
expect(created_topics.map { |t| t.allowed_users.pluck(:username) }).to match_array(
|
||||||
|
[
|
||||||
|
[user.username, Discourse.system_user.username],
|
||||||
|
[another_user.username, Discourse.system_user.username],
|
||||||
|
[group_user.username, Discourse.system_user.username],
|
||||||
|
],
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "has appropriate content from the report generator" do
|
||||||
|
automation.update(last_updated_by_id: admin.id)
|
||||||
|
automation.trigger!
|
||||||
|
|
||||||
|
expect(Post.last.raw).to include(
|
||||||
|
"Hi #{group_user.username}, your data explorer report is ready.\n\nQuery Name:\n#{query.name}",
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
|
@ -1,13 +1,13 @@
|
||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
Fabricator(:query, from: "DiscourseDataExplorer::Query") do
|
Fabricator(:query, from: DiscourseDataExplorer::Query) do
|
||||||
name
|
name { sequence(:name) { |i| "cat#{i}" } }
|
||||||
description
|
description { sequence(:desc) { |i| "description #{i}" } }
|
||||||
sql
|
sql { sequence(:sql) { |i| "SELECT * FROM users limit #{i}" } }
|
||||||
user
|
user
|
||||||
end
|
end
|
||||||
|
|
||||||
Fabricator(:query_group, from: "DiscourseDataExplorer::QueryGroup") do
|
Fabricator(:query_group, from: DiscourseDataExplorer::QueryGroup) do
|
||||||
query
|
query
|
||||||
group
|
group
|
||||||
end
|
end
|
||||||
|
|
|
@ -0,0 +1,58 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require "rails_helper"
|
||||||
|
|
||||||
|
describe DiscourseDataExplorer::ReportGenerator do
|
||||||
|
fab!(:user) { Fabricate(:user) }
|
||||||
|
fab!(:unauthorised_user) { Fabricate(:user) }
|
||||||
|
fab!(:unauthorised_group) { Fabricate(:group) }
|
||||||
|
fab!(:group) { Fabricate(:group, users: [user]) }
|
||||||
|
|
||||||
|
fab!(:query) { DiscourseDataExplorer::Query.find(-1) }
|
||||||
|
fab!(:query_group) { Fabricate(:query_group, query: query, group: group) }
|
||||||
|
|
||||||
|
let(:query_params) { [%w[from_days_ago 0], %w[duration_days 15]] }
|
||||||
|
|
||||||
|
before { SiteSetting.data_explorer_enabled = true }
|
||||||
|
|
||||||
|
describe ".generate" do
|
||||||
|
it "returns [] if the creator cannot send PMs" do
|
||||||
|
result = described_class.new(user.id).generate(query.id, query_params, [user.username])
|
||||||
|
|
||||||
|
expect(result).to eq []
|
||||||
|
end
|
||||||
|
|
||||||
|
it "returns [] if the recipient is not in query group" do
|
||||||
|
result =
|
||||||
|
described_class.new(user.id).generate(
|
||||||
|
query.id,
|
||||||
|
query_params,
|
||||||
|
[unauthorised_user.username, unauthorised_group.name],
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(result).to eq []
|
||||||
|
end
|
||||||
|
|
||||||
|
it "returns a list of pms for authorised users" do
|
||||||
|
SiteSetting.personal_message_enabled_groups = group.id
|
||||||
|
DiscourseDataExplorer::ResultToMarkdown.expects(:convert).returns("le table")
|
||||||
|
freeze_time
|
||||||
|
|
||||||
|
result = described_class.new(user.id).generate(query.id, query_params, [user.username])
|
||||||
|
|
||||||
|
expect(result).to eq(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"title" => "Scheduled Report for #{query.name}",
|
||||||
|
"target_usernames" => [user.username],
|
||||||
|
"raw" =>
|
||||||
|
"Hi #{user.username}, your data explorer report is ready.\n\n" +
|
||||||
|
"Query Name:\n#{query.name}\n\nHere are the results:\nle table\n\n" +
|
||||||
|
"<a href='/admin/plugins/explorer?id=#{query.id}'>View query in Data Explorer</a>\n\n" +
|
||||||
|
"Report created at #{Time.zone.now.strftime("%Y-%m-%d at %H:%M:%S")} (#{Time.zone.name})",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
Loading…
Reference in New Issue