diff --git a/app/controllers/events_controller.rb b/app/controllers/events_controller.rb index 91c6d5f5d3..48f0eefc52 100644 --- a/app/controllers/events_controller.rb +++ b/app/controllers/events_controller.rb @@ -947,6 +947,18 @@ def sub_organizations send_data csv, filename: "#{@event.name}'s sub-organizations.csv", type: "text/csv", disposition: :attachment end + + # Like the CSV, the XLSX export intentionally does not consider filters. + # Unlike the CSV, it includes every visible descendant (not just direct + # sub-organizations), rendered as a collapsible tree via row grouping. + format.xlsx do + send_data( + Event::SubOrganizationsExport.new(@event, descendant_ids: visible_descendant_ids).xlsx, + filename: "#{@event.name}'s sub-organizations.xlsx", + type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + disposition: :attachment + ) + end end end diff --git a/app/models/event/sub_organizations_export.rb b/app/models/event/sub_organizations_export.rb new file mode 100644 index 0000000000..5ac14b5b69 --- /dev/null +++ b/app/models/event/sub_organizations_export.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +class Event + class SubOrganizationsExport + # Excel supports at most 7 levels of row grouping. Deeper descendants are + # still exported, they just share the deepest collapsible level. + MAX_OUTLINE_LEVEL = 7 + + def initialize(event, descendant_ids:) + @event = event + @descendant_ids = descendant_ids + end + + def xlsx + io = StringIO.new + workbook = WriteXLSX.new(io) + + bold = workbook.add_format(bold: 1) + + worksheet = workbook.add_worksheet("Sub-organizations") + # Attach each group's collapse toggle to the parent row above it rather + # than the default summary row below. + worksheet.outline_settings(1, 0, 0, 0) + worksheet.set_column("A:A", 20) + worksheet.set_column("B:C", 40) + worksheet.set_column("D:D", 12) + worksheet.set_column("E:E", 30) + + worksheet.write_row(0, 0, ["ID", "Name", "Slug", "Balance", "Tags"], bold) + + @current_row = 1 + children_of(@event.id).each { |subevent| write_subtree(worksheet, subevent, 0) } + + workbook.close + io.string + end + + private + + def write_subtree(worksheet, event, depth) + tags_for_parent = event.scoped_tags.select { |tag| tag.parent_event_id == event.parent_id } + + # write_string keeps user-provided values (e.g. a name starting with "=") + # from being interpreted as formulas. + worksheet.write_string(@current_row, 0, event.public_id) + worksheet.write_string(@current_row, 1, "#{" " * depth}#{event.name}") + worksheet.write_string(@current_row, 2, event.slug) + worksheet.write_number(@current_row, 3, event.balance_v2_cents / 100.0) + worksheet.write_string(@current_row, 4, tags_for_parent.map(&:name).join(", ")) + + if depth.positive? + # Syntax: set_row(row, height, format, hidden, level, collapsed) + worksheet.set_row(@current_row, nil, nil, 0, depth.clamp(0, MAX_OUTLINE_LEVEL)) + end + + @current_row += 1 + + children_of(event.id).each { |child| write_subtree(worksheet, child, depth + 1) } + end + + def children_of(parent_id) + children_by_parent_id[parent_id] || [] + end + + def children_by_parent_id + @children_by_parent_id ||= Event.where(id: @descendant_ids) + .includes(:scoped_tags) + .order(:name) + .group_by(&:parent_id) + end + + end + +end diff --git a/app/views/events/sub_organizations.html.erb b/app/views/events/sub_organizations.html.erb index 06fd7a3262..6256a3cf92 100644 --- a/app/views/events/sub_organizations.html.erb +++ b/app/views/events/sub_organizations.html.erb @@ -9,9 +9,20 @@