Skip to content

Introduce User::PermissionsOverview and use it to render permissions to admins #11188

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions app/controllers/users_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
120 changes: 120 additions & 0 deletions app/models/user/permissions_overview.rb
Original file line number Diff line number Diff line change
@@ -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
26 changes: 26 additions & 0 deletions app/views/users/_event_permissions.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<%# locals: (node:) %>
<li>
<% badge_class =
case node.role
when "reader"
"bg-success"
when "member"
"bg-warning"
when "manager"
"bg-error"
else
"bg-muted"
end %>

<div class="badge <%= badge_class %> ml0"><%= node.role&.humanize || "None" %></div>

<%= link_to node.event.name, event_path(node.event), data: { turbo_frame: "_top" } %>

<% if node.child_nodes.present? %>
<ul>
<% node.child_nodes.each do |child_node| %>
<%= render(partial: "event_permissions", locals: { node: child_node }) %>
<% end %>
</ul>
<% end %>
</li>
12 changes: 12 additions & 0 deletions app/views/users/admin_details.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -280,4 +280,16 @@
</table>
</div>
<% end %>

<% if @permissions_overview.event_graph.present? %>
<div class="p-4">
<h3 class="mb2 mt1">Permissions</h3>

<ul>
<% @permissions_overview.event_graph.each do |node| %>
<%= render(partial: "event_permissions", locals: { node: }) %>
<% end %>
</ul>
</div>
<% end %>
<% end %>
130 changes: 130 additions & 0 deletions spec/models/user/permissions_overview_spec.rb
Original file line number Diff line number Diff line change
@@ -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