From 15efb45cb7cf320a12190e4e504e639c2020779d Mon Sep 17 00:00:00 2001 From: Selase Krakani <849886+s3lase@users.noreply.github.com> Date: Tue, 29 Aug 2023 16:10:42 +0000 Subject: [PATCH] AUTOMATION: FEATURE: Add script to modify user group memberships through badges (#206) --- .../automation/config/locales/client.en.yml | 10 + .../automation/config/locales/server.en.yml | 3 + .../user_group_membership_through_badge.rb | 96 ++++++ plugins/automation/plugin.rb | 1 + ...ser_group_membership_through_badge_spec.rb | 285 ++++++++++++++++++ 5 files changed, 395 insertions(+) create mode 100644 plugins/automation/lib/discourse_automation/scripts/user_group_membership_through_badge.rb create mode 100644 plugins/automation/spec/scripts/user_group_membership_through_badge_spec.rb diff --git a/plugins/automation/config/locales/client.en.yml b/plugins/automation/config/locales/client.en.yml index 365c8237..6ae1a29c 100644 --- a/plugins/automation/config/locales/client.en.yml +++ b/plugins/automation/config/locales/client.en.yml @@ -225,6 +225,16 @@ en: info: Info success: Success error: Error + user_group_membership_through_badge: + fields: + badge_name: + label: Badge Name + group: + label: Group + description: Target group. Users with the specified badge will be added to this group + remove_members_without_badge: + label: Remove existing members without badge + description: Optional, Remove existing group members without the specified badge suspend_user_by_email: fields: suspend_until: diff --git a/plugins/automation/config/locales/server.en.yml b/plugins/automation/config/locales/server.en.yml index dac6e60f..26538d56 100644 --- a/plugins/automation/config/locales/server.en.yml +++ b/plugins/automation/config/locales/server.en.yml @@ -91,6 +91,9 @@ en: user_global_notice: title: User global notice description: Allows to display a global notice for a user + user_group_membership_through_badge: + title: User Group Membership through Badge + description: Modify user group membership based on a badge suspend_user_by_email_with_api_call: doc: When triggering `suspend_user_by_email` with an api call, the endpoint expects a valid `email` to be present in the params sent. `reasons` and `suspend_until (ISO 8601 format)` can also be used to override default fields values. user_global_notice_with_stalled_topic: diff --git a/plugins/automation/lib/discourse_automation/scripts/user_group_membership_through_badge.rb b/plugins/automation/lib/discourse_automation/scripts/user_group_membership_through_badge.rb new file mode 100644 index 00000000..8136fa63 --- /dev/null +++ b/plugins/automation/lib/discourse_automation/scripts/user_group_membership_through_badge.rb @@ -0,0 +1,96 @@ +# frozen_string_literal: true + +DiscourseAutomation::Scriptable::USER_GROUP_MEMBERSHIP_THROUGH_BADGE = + "user_group_membership_through_badge" +DiscourseAutomation::Scriptable::USER_GROUP_MEMBERSHIP_THROUGH_BADGE_BULK_MODIFY_START_COUNT = 1000 + +DiscourseAutomation::Scriptable.add( + DiscourseAutomation::Scriptable::USER_GROUP_MEMBERSHIP_THROUGH_BADGE, +) do + version 1 + + field :badge_name, component: :text, required: true + field :group, component: :group, required: true + field :remove_members_without_badge, component: :boolean + + triggerables %i[recurring user_first_logged_in] + + script do |context, fields| + badge_name = fields.dig("badge_name", "value").strip + group_id = fields.dig("group", "value") + remove_members_without_badge = fields.dig("remove_members_without_badge", "value") + current_user = context["user"] + bulk_modify_start_count = + DiscourseAutomation::Scriptable::USER_GROUP_MEMBERSHIP_THROUGH_BADGE_BULK_MODIFY_START_COUNT + + badge = Badge.find_by(name: badge_name) + unless badge + Rails.logger.warn("[discourse-automation] Couldn’t find badge with name #{badge_name}") + next + end + + group = Group.find_by(id: group_id) + unless group + Rails.logger.warn("[discourse-automation] Couldn’t find group with id #{group_id}") + next + end + + query_options = { group_id: group.id, badge_id: badge.id } + + # IDs of users who currently have badge but not members of target group + user_ids_to_add_query = +<<~SQL + SELECT u.id AS user_id + FROM users u + JOIN user_badges ub ON u.id = ub.user_id + LEFT JOIN group_users gu ON u.id = gu.user_id AND gu.group_id = :group_id + WHERE ub.badge_id = :badge_id AND gu.user_id IS NULL + SQL + + if current_user + user_ids_to_add_query << " AND u.id = :user_id" + query_options[:user_id] = current_user.id + end + + user_ids_to_add = DB.query_single(user_ids_to_add_query, query_options) + + if user_ids_to_add.count < bulk_modify_start_count + User + .where(id: user_ids_to_add) + .each do |user| + group.add(user) + GroupActionLogger.new(Discourse.system_user, group).log_add_user_to_group(user) + end + else + group.bulk_add(user_ids_to_add) + end + + next unless remove_members_without_badge + + # IDs of users who are currently target group members without the badge + user_ids_to_remove_query = +<<~SQL + SELECT u.id AS user_id + FROM users u + JOIN group_users gu ON u.id = gu.user_id + LEFT JOIN user_badges ub ON u.id = ub.user_id AND ub.badge_id = :badge_id + WHERE gu.group_id = :group_id AND ub.user_id IS NULL + SQL + + if current_user + user_ids_to_remove_query << " AND u.id = :user_id" + query_options[:user_id] ||= current_user.id + end + + user_ids_to_remove = DB.query_single(user_ids_to_remove_query, query_options) + + if user_ids_to_remove.count < bulk_modify_start_count + User + .where(id: user_ids_to_remove) + .each do |user| + group.remove(user) + GroupActionLogger.new(Discourse.system_user, group).log_remove_user_from_group(user) + end + else + group.bulk_remove(user_ids_to_remove) + end + end +end diff --git a/plugins/automation/plugin.rb b/plugins/automation/plugin.rb index a50a5a50..3f52500f 100644 --- a/plugins/automation/plugin.rb +++ b/plugins/automation/plugin.rb @@ -53,6 +53,7 @@ module ::DiscourseAutomation lib/discourse_automation/scripts/suspend_user_by_email lib/discourse_automation/scripts/topic_required_words lib/discourse_automation/scripts/user_global_notice + lib/discourse_automation/scripts/user_group_membership_through_badge lib/discourse_automation/scripts/zapier_webhook lib/discourse_automation/triggers/after_post_cook lib/discourse_automation/triggers/api_call diff --git a/plugins/automation/spec/scripts/user_group_membership_through_badge_spec.rb b/plugins/automation/spec/scripts/user_group_membership_through_badge_spec.rb new file mode 100644 index 00000000..a58f049f --- /dev/null +++ b/plugins/automation/spec/scripts/user_group_membership_through_badge_spec.rb @@ -0,0 +1,285 @@ +# frozen_string_literal: true + +require_relative "../discourse_automation_helper" + +describe "UserGroupMembershipThroughBadge" do + fab!(:user) { Fabricate(:user) } + fab!(:other_users) { Fabricate.times(5, :user) } + fab!(:badge) { Fabricate(:badge) } + fab!(:target_group) { Fabricate(:group) } + + fab!(:automation) do + Fabricate( + :automation, + script: DiscourseAutomation::Scriptable::USER_GROUP_MEMBERSHIP_THROUGH_BADGE, + ) + end + + before { BadgeGranter.enable_queue } + after do + BadgeGranter.disable_queue + BadgeGranter.clear_queue! + end + + def target_group_member?(user_ids) + GroupUser.exists?(group_id: target_group.id, user_id: user_ids) + end + + def owns_badge?(user_ids) + UserBadge.exists?(user_id: user_ids, badge_id: badge.id) + end + + context "with invalid field values" do + before do + @original_logger = Rails.logger + Rails.logger = @fake_logger = FakeLogger.new + end + + after { Rails.logger = @original_logger } + + context "with an unknown badge" do + let(:unknown_badge_name) { "Unknown Badge" } + + before do + automation.upsert_field!( + "badge_name", + "text", + { value: unknown_badge_name }, + target: "script", + ) + automation.trigger!( + "kind" => DiscourseAutomation::Triggerable::USER_FIRST_LOGGED_IN, + "user" => user, + ) + end + + it "logs warning message and does nothing" do + expect(@fake_logger.warnings).to include( + "[discourse-automation] Couldn’t find badge with name #{unknown_badge_name}", + ) + expect(user.reload.groups).to be_empty + end + end + + context "with a non-existent group" do + before do + automation.upsert_field!("badge_name", "text", { value: badge.name }, target: "script") + automation.upsert_field!("group", "group", { value: target_group.id }, target: "script") + end + + it "logs warning message and does nothing" do + target_group.destroy + + automation.trigger!( + "kind" => DiscourseAutomation::Triggerable::USER_FIRST_LOGGED_IN, + "user" => user, + ) + + expect(@fake_logger.warnings).to include( + "[discourse-automation] Couldn’t find group with id #{target_group.id}", + ) + expect(user.reload.groups).to be_empty + end + end + end + + context "with valid field values" do + before do + automation.upsert_field!("badge_name", "text", { value: badge.name }, target: "script") + automation.upsert_field!("group", "group", { value: target_group.id }, target: "script") + end + + context "when triggered with a user" do + context "when user has badge" do + before { BadgeGranter.grant(badge, user) } + + it "adds user to group" do + expect(target_group_member?([user.id])).to eq(false) + + expect do + automation.trigger!( + "kind" => DiscourseAutomation::Triggerable::USER_FIRST_LOGGED_IN, + "user" => user, + ) + end.to change { target_group.users.count }.by(1) + + expect(target_group_member?([user.id])).to eq(true) + expect(owns_badge?([user.id])).to eq(true) + end + + it "does nothing if user is an existing group member" do + target_group.add(user) + user.reload + current_membership = user.group_users.find_by(group_id: target_group.id) + + expect(current_membership).not_to be_nil + + expect do + automation.trigger!( + "kind" => DiscourseAutomation::Triggerable::USER_FIRST_LOGGED_IN, + "user" => user, + ) + end.not_to change { target_group.reload.users.count } + + expect(GroupUser.find_by(group_id: target_group.id, user_id: user.id)).to eq( + current_membership, + ) + expect(owns_badge?([user.id])).to eq(true) + end + + it "does not add other badge owners" do + other_users.each { |u| BadgeGranter.grant(badge, u) } + + expect(badge.user_badges.count).to eq(6) + expect(target_group.users.count).to eq(0) + + expect do + automation.trigger!( + "kind" => DiscourseAutomation::Triggerable::USER_FIRST_LOGGED_IN, + "user" => user, + ) + end.to change { target_group.reload.users.count }.by(1) + + expect(target_group_member?([user.id])).to eq(true) + expect(owns_badge?([user.id])).to eq(true) + end + end + + context "when user does not have badge" do + it "does not add user to group" do + expect(target_group_member?([user.id])).to eq(false) + expect(owns_badge?([user.id])).to eq(false) + + expect do + automation.trigger!( + "kind" => DiscourseAutomation::Triggerable::USER_FIRST_LOGGED_IN, + "user" => user, + ) + end.not_to change { target_group.users.count } + + expect(target_group_member?([user.id])).to eq(false) + expect(owns_badge?([user.id])).to eq(false) + end + + it "does not add other badge owners" do + other_users.each { |u| BadgeGranter.grant(badge, u) } + + expect(badge.user_badges.count).to eq(5) + expect(target_group.users.count).to eq(0) + + expect do + automation.trigger!( + "kind" => DiscourseAutomation::Triggerable::USER_FIRST_LOGGED_IN, + "user" => user, + ) + end.not_to change { target_group.reload.users.count } + + expect(target_group_member?([user.id])).to eq(false) + expect(owns_badge?([user.id])).to eq(false) + expect(target_group.users.count).to eq(0) + end + end + end + + context "when triggered without a user" do + let(:badge_owners) { other_users.first(3) } + let(:non_badge_owners) { other_users.last(2) } + let(:badge_owner_ids) { badge_owners.map(&:id) } + let(:non_badge_owner_ids) { non_badge_owners.map(&:id) } + + before { badge_owners.each { |u| BadgeGranter.grant(badge, u) } } + + it "adds all users with badge to group" do + expect(target_group_member?(badge_owner_ids)).to eq(false) + expect(target_group_member?(non_badge_owner_ids)).to eq(false) + + expect do + automation.trigger!("kind" => DiscourseAutomation::Triggerable::RECURRING) + end.to change { target_group.reload.users.count }.by(badge_owners.size) + + expect(target_group_member?(badge_owner_ids)).to eq(true) + expect(target_group_member?(non_badge_owner_ids)).to eq(false) + end + + it "skips existing group members with badge" do + badge_owners.each { |u| target_group.add(u) } + + expect(target_group_member?(badge_owner_ids)).to eq(true) + expect(target_group_member?(non_badge_owner_ids)).to eq(false) + + expect do + automation.trigger!("kind" => DiscourseAutomation::Triggerable::RECURRING) + end.not_to change { target_group.reload.users.count } + + expect(target_group_member?(badge_owner_ids)).to eq(true) + expect(target_group_member?(non_badge_owner_ids)).to eq(false) + end + end + + context "with remove_members_without_badge = true" do + before do + automation.upsert_field!( + "remove_members_without_badge", + "boolean", + { value: true }, + target: "script", + ) + other_users.each { |u| target_group.add(u) } + end + + it "removes existing members without badge" do + expect(target_group_member?(other_users.map(&:id))).to eq(true) + + expect do + automation.trigger!("kind" => DiscourseAutomation::Triggerable::RECURRING) + end.to change { target_group.reload.users.count }.by(-other_users.count) + + expect(target_group_member?(other_users.map(&:id))).to eq(false) + end + + it "keeps existing members with badge" do + BadgeGranter.grant(badge, user) + target_group.add(user) + + expect(target_group_member?(other_users.map(&:id))).to eq(true) + expect(owns_badge?(other_users.map(&:id))).to eq(false) + expect(target_group_member?([user.id])).to eq(true) + expect(owns_badge?([user.id])).to eq(true) + + expect do + automation.trigger!("kind" => DiscourseAutomation::Triggerable::RECURRING) + end.to change { target_group.reload.users.count } + + expect(target_group_member?(other_users.map(&:id))).to eq(false) + expect(owns_badge?(other_users.map(&:id))).to eq(false) + expect(target_group_member?([user.id])).to eq(true) + expect(owns_badge?([user.id])).to eq(true) + end + end + + context "with remove_members_without_badge = false" do + before do + automation.upsert_field!( + "remove_members_without_badge", + "boolean", + { value: false }, + target: "script", + ) + end + + it "keeps existing members without badge" do + other_users.each { |u| target_group.add(u) } + + expect(target_group_member?(other_users.map(&:id))).to eq(true) + expect(owns_badge?(other_users.map(&:id))).to eq(false) + + expect do + automation.trigger!("kind" => DiscourseAutomation::Triggerable::RECURRING) + end.not_to change { target_group.reload.users.count } + + expect(target_group_member?(other_users.map(&:id))).to eq(true) + expect(owns_badge?(other_users.map(&:id))).to eq(false) + end + end + end +end