From 3585be38c30c8d1d70aa733e48bb72e3526369f2 Mon Sep 17 00:00:00 2001 From: David Cornu Date: Thu, 31 Jul 2025 13:10:55 -0400 Subject: [PATCH 1/2] Introduce `User::PermissionsOverview` --- app/models/user/permissions_overview.rb | 120 ++++++++++++++++ spec/models/user/permissions_overview_spec.rb | 130 ++++++++++++++++++ 2 files changed, 250 insertions(+) create mode 100644 app/models/user/permissions_overview.rb create mode 100644 spec/models/user/permissions_overview_spec.rb diff --git a/app/models/user/permissions_overview.rb b/app/models/user/permissions_overview.rb new file mode 100644 index 0000000000..9a3ace343a --- /dev/null +++ b/app/models/user/permissions_overview.rb @@ -0,0 +1,120 @@ +# frozen_string_literal: true + +class User + class PermissionsOverview + Node = Struct.new(:event, :role, :child_nodes, keyword_init: true) + + def initialize(user:) + @user = user + end + + def role_by_event_id + @role_by_event_id ||= compute_role_by_event_id + end + + def event_graph + events_by_id + .values + .select { |event| event.parent_id.nil? } + .map { |event| event_node(event) } + end + + private + + attr_reader(:user) + + def compute_role_by_event_id + events_by_id.to_h do |event_id, _event| + ancestor_roles = ancestor_event_ids(event_id).filter_map do |event_id| + organizer_positions_by_event_id[event_id]&.role + end + + # If the user is a manager of this or any ancestor events they are + # considered a manager + if ancestor_roles.include?("manager") + next [event_id, "manager"] + end + + # If a user is a member of this event, they are considered a member + # (we do not take ancestor roles into account here) + op = organizer_positions_by_event_id[event_id] + if op&.role == "member" + next [event_id, "member"] + end + + # If a user is a reader of this or any ancestor events they are + # considered a reader + if ancestor_roles.present? + next [event_id, "reader"] + end + + [event_id, nil] + end + end + + def organizer_positions_by_event_id + @organizer_positions_by_event_id ||= + user + .organizer_positions + .strict_loading + .index_by(&:event_id) + end + + def events_by_id + @events_by_id ||= begin + active_events = Event.unscoped.where(Event.paranoid_default_scope) + + recursive_query = ->(non_recursive_term, recursive_term) { + Event + .unscoped + .with_recursive(event_graph: [non_recursive_term, recursive_term]) + .select("event_graph.*") + .from("event_graph") + .strict_loading + } + + descendants = recursive_query.call( + active_events.where(parent_id: organizer_positions_by_event_id.keys), + active_events.joins("JOIN event_graph ON events.parent_id = event_graph.id"), + ) + + ancestors = recursive_query.call( + active_events.where(id: organizer_positions_by_event_id.keys), + active_events.joins("JOIN event_graph ON events.id = event_graph.parent_id") + ) + + (ancestors + descendants).index_by(&:id) + end + end + + def events_by_parent_id + @events_by_parent_id ||= events_by_id.values.group_by(&:parent_id) + end + + def ancestor_event_ids(event_id) + Enumerator.new do |yielder| + current_event_id = event_id + + until current_event_id.nil? + yielder << current_event_id + + current_event = events_by_id.fetch(current_event_id) + current_event_id = current_event.parent_id + end + end + end + + def event_node(event) + role = role_by_event_id.fetch(event.id) + + child_nodes = + events_by_parent_id + .fetch(event.id, []) + .map { |event| event_node(event) } + + Node.new(event:, role:, child_nodes:) + end + + end + +end diff --git a/spec/models/user/permissions_overview_spec.rb b/spec/models/user/permissions_overview_spec.rb new file mode 100644 index 0000000000..0afda4f27c --- /dev/null +++ b/spec/models/user/permissions_overview_spec.rb @@ -0,0 +1,130 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe User::PermissionsOverview do + describe "#event_graph" do + specify "users are managers for descendants but not ancestors" do + user = create(:user) + create_nested_events(user:, roles: [nil, "manager", nil]) + + instance = described_class.new(user: user) + + expect(snapshot(instance.event_graph).string).to eq(<<~SNAPSHOT) + > name="Level 1", role=nil + > name="Level 2", role="manager" + > name="Level 3", role="manager" + SNAPSHOT + + check_against_original_code(user, instance.event_graph) + end + + specify "manager roles take precedence over lesser roles" do + user = create(:user) + create_nested_events(user:, roles: [nil, "manager", "reader"]) + + instance = described_class.new(user: user) + + expect(snapshot(instance.event_graph).string).to eq(<<~SNAPSHOT) + > name="Level 1", role=nil + > name="Level 2", role="manager" + > name="Level 3", role="manager" + SNAPSHOT + + check_against_original_code(user, instance.event_graph) + end + + specify "member roles only apply to one level and become reader for descendants" do + user = create(:user) + create_nested_events(user:, roles: [nil, "member", nil]) + + instance = described_class.new(user: user) + + expect(snapshot(instance.event_graph).string).to eq(<<~SNAPSHOT) + > name="Level 1", role=nil + > name="Level 2", role="member" + > name="Level 3", role="reader" + SNAPSHOT + + check_against_original_code(user, instance.event_graph) + end + + specify "users are reader for descendants but not ancestors" do + user = create(:user) + + create_nested_events(user:, roles: [nil, "reader", nil]) + + instance = described_class.new(user: user) + + expect(snapshot(instance.event_graph).string).to eq(<<~SNAPSHOT) + > name="Level 1", role=nil + > name="Level 2", role="reader" + > name="Level 3", role="reader" + SNAPSHOT + + check_against_original_code(user, instance.event_graph) + end + + it "correctly handles multiple trees" do + user = create(:user) + + create_nested_events(user:, roles: [nil, "member", nil], name_prefix: "Tree 1 Level") + create_nested_events(user:, roles: [nil, "manager", nil], name_prefix: "Tree 2 Level") + + instance = described_class.new(user: user) + + expect(snapshot(instance.event_graph).string).to eq(<<~SNAPSHOT) + > name="Tree 1 Level 1", role=nil + > name="Tree 1 Level 2", role="member" + > name="Tree 1 Level 3", role="reader" + > name="Tree 2 Level 1", role=nil + > name="Tree 2 Level 2", role="manager" + > name="Tree 2 Level 3", role="manager" + SNAPSHOT + + check_against_original_code(user, instance.event_graph) + end + + def create_nested_events(user:, roles:, name_prefix: "Level") + parent = nil + + roles.each_with_index do |role, index| + event = create(:event, name: "#{name_prefix} #{index + 1}", parent:, plan: Event::Plan::Standard.new) + + unless role.nil? + create(:organizer_position, event:, user:, role:) + end + + parent = event + end + end + + def snapshot(nodes, out: StringIO.new, indent: 0) + nodes.each do |node| + out.puts "#{" " * indent}> name=#{node.event.name.inspect}, role=#{node.role.inspect}" + + snapshot(node.child_nodes, out:, indent: indent + 1) + end + + out + end + + def check_against_original_code(user, nodes) + nodes.each do |node| + if node.role.nil? + expect(OrganizerPosition.role_at_least?(user, node.event, "reader")).to( + eq(false), + "user should not have \"reader\" role on #{node.event.name.inspect}" + ) + else + expect(OrganizerPosition.role_at_least?(user, node.event, node.role)).to( + eq(true), + "user should have at least #{node.role.inspect} for event #{node.event.name.inspect}" + ) + end + + check_against_original_code(user, node.child_nodes) + end + end + end +end From 01b2fb337627641ad698f2c117252cbbcddf70c0 Mon Sep 17 00:00:00 2001 From: David Cornu Date: Fri, 1 Aug 2025 14:20:39 -0400 Subject: [PATCH 2/2] Render user permissions in admin details --- app/controllers/users_controller.rb | 1 + app/views/users/_event_permissions.html.erb | 26 +++++++++++++++++++++ app/views/users/admin_details.html.erb | 12 ++++++++++ 3 files changed, 39 insertions(+) create mode 100644 app/views/users/_event_permissions.html.erb diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index 9f6b2c49ef..138f519241 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -252,6 +252,7 @@ def admin_details @lob_checks = Check.where(creator: @user) @ach_transfers = AchTransfer.where(creator: @user) @disbursements = Disbursement.where(requested_by: @user) + @permissions_overview = User::PermissionsOverview.new(user: @user) authorize @user end diff --git a/app/views/users/_event_permissions.html.erb b/app/views/users/_event_permissions.html.erb new file mode 100644 index 0000000000..44cff8604f --- /dev/null +++ b/app/views/users/_event_permissions.html.erb @@ -0,0 +1,26 @@ +<%# locals: (node:) %> +
  • + <% badge_class = + case node.role + when "reader" + "bg-success" + when "member" + "bg-warning" + when "manager" + "bg-error" + else + "bg-muted" + end %> + +
    <%= node.role&.humanize || "None" %>
    + + <%= link_to node.event.name, event_path(node.event), data: { turbo_frame: "_top" } %> + + <% if node.child_nodes.present? %> +
      + <% node.child_nodes.each do |child_node| %> + <%= render(partial: "event_permissions", locals: { node: child_node }) %> + <% end %> +
    + <% end %> +
  • diff --git a/app/views/users/admin_details.html.erb b/app/views/users/admin_details.html.erb index 5c0e73e243..ef6f0d4bff 100644 --- a/app/views/users/admin_details.html.erb +++ b/app/views/users/admin_details.html.erb @@ -280,4 +280,16 @@ <% end %> + + <% if @permissions_overview.event_graph.present? %> +
    +

    Permissions

    + +
      + <% @permissions_overview.event_graph.each do |node| %> + <%= render(partial: "event_permissions", locals: { node: }) %> + <% end %> +
    +
    + <% end %> <% end %>