Skip to content
Draft
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 AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,7 @@ action, or `authorize! :workshop, to: :summary?`).
- `FormResponseAggregator` β€” Rolls up every submission to one form into a per-question report for the `/forms/:id/results` page (`FormsController#results`): selectable questions become chartable `[ label, count ]` tallies (sector/age-group ids resolved to names, `Other: <text>` write-ins split into their own list), geographic fields (`mailing_state`/`organization_state`/`ce_license_issuing_state` β†’ US map, `mailing_country`/`organization_country` β†’ world map) become choropleths, free-text questions become newest-first answer lists, file questions a count. Returns `FieldReport` structs in form order; rendered via the shared `events/_breakdown_card` partial (pie/bar/map/world_map) plus `forms/_text_responses_card`
- `ModelDeduper` β€” Deduplication logic
- `RichTextMigrator` β€” Rich text migration utility
- `EventRegistrationImporter` β€” Bulk-imports attended registrants into one existing event from an uploaded spreadsheet (.xlsx/.csv/.xls, columns FirstName/LastName/Organization/EMail; uses the `roo` gem). Every row becomes an `attended` EventRegistration; the person is matched on lower(email)+lower(last_name) (PublicRegistration's rule) and only created on a miss, organizations are matched by name and linked (never created), and an existing non-attended registration is promoted. Runs in a transaction with a `dry_run` mode that rolls back for the preview interstitial; returns a `Result` + per-row `RowPreview`s. Drives `EventRegistrationImportsController` (`event_registrations/import`, admin-only via `EventRegistrationPolicy#import?`)
- `StoryImporter` β€” Imports stories from a WordPress Posts Export CSV. Every row becomes a Story (published per the WP Status); a non-AWBW author's story also gets a promoted StoryIdea. Resolves the author Person from the facilitator name (unresolvable names kept as a Comment), converts content via wpautop, translates Categories/Tags/User Categories/who_is_your_story_about into Sectors + Categories via `config/story_import_sector_mapping.yml`, resolves orgs via `config/story_import_organization_mapping.yml`, links grant-tagged stories through the author's Scholarship, enqueues a `StoryAssetImportJob` per story to download its "Image URL" images in the background, and returns a row-by-row preview for the dry-run interstitial
- `AssetUrlImporter` β€” Downloads a remote file URL and attaches the bytes to ActiveStorage on the given owner as an Asset (open-uri β†’ attach); the subclass's content-type validation still applies
- `FeatureCatalog` β€” Syncs the checked-in feature seed (`config/features.yml`) into the `Feature` table behind the `/features` page (the "Sync latest updates" button). `#import!` (matched by `name`) creates missing features, re-aligns catalog-owned classification (`CATALOG_FIELDS` β€” area, display_status, released_on, action_path, pr_number, so seed corrections propagate), and fills blank admin content (`CONTENT_FIELDS` β€” summary, pro_tips, external_url, rhino_description) without overwriting it; returns a `Result` (`created`/`updated`)
Expand Down
2 changes: 2 additions & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -118,3 +118,5 @@ gem "opentelemetry-instrumentation-all"

# Error monitoring (production-only via initializer guard)
gem "honeybadger", "~> 6.4"

gem "roo", "~> 2.10"
9 changes: 7 additions & 2 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -622,6 +622,9 @@ GEM
actionpack (>= 7.0)
railties (>= 7.0)
rexml (3.4.4)
roo (2.10.1)
nokogiri (~> 1)
rubyzip (>= 1.3.0, < 3.0.0)
rouge (4.7.0)
rspec-core (3.13.6)
rspec-support (~> 3.13.0)
Expand Down Expand Up @@ -674,7 +677,7 @@ GEM
ffi (~> 1.12)
logger
ruby2_keywords (0.0.5)
rubyzip (3.2.2)
rubyzip (2.4.1)
safely_block (1.0.0)
search_cop (1.5.0)
treetop
Expand Down Expand Up @@ -822,6 +825,7 @@ DEPENDENCIES
puma (~> 7.2, >= 7.2.1)
rack-mini-profiler (~> 4.0)
rails (~> 8.1.0)
roo (~> 2.10)
rspec-rails
rubocop-rails-omakase
search_cop
Expand Down Expand Up @@ -1069,6 +1073,7 @@ CHECKSUMS
request_store (1.7.0) sha256=e1b75d5346a315f452242a68c937ef8e48b215b9453a77a6c0acdca2934c88cb
responders (3.2.0) sha256=89c2d6ac0ae16f6458a11524cae4a8efdceba1a3baea164d28ee9046bd3df55a
rexml (3.4.4) sha256=19e0a2c3425dfbf2d4fc1189747bdb2f849b6c5e74180401b15734bc97b5d142
roo (2.10.1) sha256=cbb43bc955f9c110e74b721c835fb9bd3515b63af88ec709ac87fbf30f8be70e
rouge (4.7.0) sha256=dba5896715c0325c362e895460a6d350803dbf6427454f49a47500f3193ea739
rspec-core (3.13.6) sha256=a8823c6411667b60a8bca135364351dda34cd55e44ff94c4be4633b37d828b2d
rspec-expectations (3.13.5) sha256=33a4d3a1d95060aea4c94e9f237030a8f9eae5615e9bd85718fe3a09e4b58836
Expand All @@ -1084,7 +1089,7 @@ CHECKSUMS
ruby-progressbar (1.13.0) sha256=80fc9c47a9b640d6834e0dc7b3c94c9df37f08cb072b7761e4a71e22cff29b33
ruby-vips (2.3.0) sha256=e685ec02c13969912debbd98019e50492e12989282da5f37d05f5471442f5374
ruby2_keywords (0.0.5) sha256=ffd13740c573b7301cf7a2e61fc857b2a8e3d3aff32545d6f8300d8bae10e3ef
rubyzip (3.2.2) sha256=c0ed99385f0625415c8f05bcae33fe649ed2952894a95ff8b08f26ca57ea5b3c
rubyzip (2.4.1) sha256=8577c88edc1fde8935eb91064c5cb1aef9ad5494b940cf19c775ee833e075615
safely_block (1.0.0) sha256=bea80e084e620adeada62fd98cf461bbb9888d40dc0f06d337df9d01e1a658e5
search_cop (1.5.0) sha256=e8f3fb5073b32e3220cffe49f4334611a654ab62a8b56b216f04772e6e3f91f3
securerandom (0.4.1) sha256=cc5193d414a4341b6e225f0cb4446aceca8e50d5e1888743fac16987638ea0b1
Expand Down
94 changes: 94 additions & 0 deletions app/controllers/event_registration_imports_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
class EventRegistrationImportsController < ApplicationController
# Admin-only flow for bulk-importing attended registrants into one event from a
# spreadsheet (.xlsx / .csv).
#
# new β†’ pick the event + upload form
# create β†’ dry-run preview of what would be created (nothing written)
# confirm β†’ run the real import against the stashed file
#
# The uploaded file is stashed as an ActiveStorage blob between the preview and
# confirm steps (too large for the cookie session), and purged once the import
# runs. The chosen event id and the file extension travel through the preview's
# hidden fields so confirm can reopen the extension-less blob with Roo.

def new
authorize! EventRegistration, to: :import?
@events = importable_events
@event = Event.find_by(id: params[:event_id])
end

def create
authorize! EventRegistration, to: :import?
@events = importable_events
@event = Event.find_by(id: params[:event_id])
file = params[:file]

return render_new_error("Choose an event to import into.") if @event.nil?
return render_new_error("Choose a spreadsheet file to import.") if file.blank?
return render_new_error("That file must be a .xlsx, .csv, or .xls.") unless supported?(file)

@filename = file.original_filename
@extension = extension_for(file)
begin
@blob = ActiveStorage::Blob.create_and_upload!(
io: file.open, filename: @filename,
content_type: file.content_type.presence || "application/octet-stream"
)
@result = run_import(file.path, dry_run: true)
rescue => e
@blob&.purge
return render_new_error("Could not read that file: #{e.message}")
end
# Rendering (not redirecting) on a POST: Turbo only displays non-redirect form
# responses when the status is 4xx/5xx, so the preview needs 422.
render :create, status: :unprocessable_content
end

def confirm
authorize! EventRegistration, to: :import?
@event = Event.find(params[:event_id])
blob = ActiveStorage::Blob.find_signed!(params[:signed_id])
result = blob.open do |tempfile|
run_import(tempfile.path, dry_run: false, extension: params[:extension])
end
blob.purge

redirect_to event_registrations_path(event_id: @event.id, admin: true), notice: import_notice(result)
rescue ActiveSupport::MessageVerifier::InvalidSignature, ActiveRecord::RecordNotFound
redirect_to new_event_registration_import_path,
alert: "That upload is no longer available β€” please choose the file again."
end

private

def run_import(path, dry_run:, extension: @extension)
EventRegistrationImporter.call(
file_path: path, event: @event, extension: extension,
import_user: current_user, dry_run: dry_run
)
end

def importable_events
Event.order(start_date: :desc)
end

def supported?(file)
extension_for(file).in?(EventRegistrationImporter::SUPPORTED_EXTENSIONS)
end

def extension_for(file)
File.extname(file.original_filename.to_s).delete(".").downcase
end

def render_new_error(message)
flash.now[:alert] = message
render :new, status: :unprocessable_content
end

def import_notice(result)
"Import complete β€” #{result.registrations_created} attended registrations created " \
"(#{result.people_created} new people, #{result.people_matched} matched)" \
"#{", #{result.registrations_promoted} promoted to attended" if result.registrations_promoted.positive?}" \
"#{", #{result.skipped.size} rows skipped" if result.skipped.any?}."
end
end
2 changes: 2 additions & 0 deletions app/policies/event_registration_policy.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ def update? = admin? || owner?
def destroy? = record.persisted? && (admin? || owner?)
def show? = admin?
def show_public? = true
# Bulk-importing attended registrants from a spreadsheet β€” admins only.
def import? = admin?
def confirm? = admin?
def process_confirm? = admin?
def transfer? = admin?
Expand Down
230 changes: 230 additions & 0 deletions app/services/event_registration_importer.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,230 @@
require "roo"

# Bulk-imports event registrants into one existing event from an uploaded
# spreadsheet (.xlsx / .csv / .xls) whose columns are FirstName, LastName,
# Organization, EMail. Every row becomes an "attended" EventRegistration on the
# chosen event β€” the sheet is a roster of people who have already completed the
# training (e.g. the on-demand facilitator training export).
#
# A row's person is matched (never duplicated) on lower(email) + lower(last_name)
# β€” the same rule PublicRegistration uses β€” and only created when no one matches.
# Organizations are matched by name and linked to the registration, but never
# created: an unrecognized org name is reported, not invented. Runs in a
# transaction with a dry_run mode that rolls back so the controller can show a
# per-row preview before anything is written.
class EventRegistrationImporter
IMPORTED_STATUS = "attended".freeze

# Header label (lowercased, whitespace-collapsed) β†’ the field we read from the
# row, tolerant of the common spellings staff export.
HEADER_ALIASES = {
"firstname" => :first_name,
"first name" => :first_name,
"lastname" => :last_name,
"last name" => :last_name,
"name" => :last_name,
"organization" => :organization,
"organisation" => :organization,
"org" => :organization,
"email" => :email,
"e-mail" => :email,
"email address" => :email
}.freeze

SUPPORTED_EXTENSIONS = %w[xlsx csv xls].freeze

Result = Struct.new(
:rows_processed, :people_created, :people_matched,
:registrations_created, :registrations_promoted, :registrations_already_attended,
:organizations_linked, :organizations_unmatched,
:skipped, :rows,
keyword_init: true
) do
def registrations_total
registrations_created + registrations_promoted + registrations_already_attended
end

def summary
[
"rows processed: #{rows_processed}",
"people matched: #{people_matched}, created: #{people_created}",
"registrations created: #{registrations_created}, promoted to attended: #{registrations_promoted}, " \
"already attended: #{registrations_already_attended}",
"organizations linked: #{organizations_linked}, unmatched: #{organizations_unmatched}",
"skipped: #{skipped.size}"
].join("\n")
end
end

# A row-level summary for the preview interstitial: what the row would create,
# match, or skip. person_status/registration_status/organization_status are the
# symbols the preview view renders as badges.
RowPreview = Struct.new(
:number, :first_name, :last_name, :email, :organization_name,
:person_status, :person_label,
:registration_status, :organization_status,
:skipped_reason,
keyword_init: true
)

def self.call(...)
new(...).call
end

def initialize(file_path:, event:, extension:, import_user: nil, dry_run: false)
@file_path = file_path
@event = event
@extension = extension.to_s.downcase
@import_user = import_user
@dry_run = dry_run
@result = Result.new(
rows_processed: 0, people_created: 0, people_matched: 0,
registrations_created: 0, registrations_promoted: 0, registrations_already_attended: 0,
organizations_linked: 0, organizations_unmatched: 0,
skipped: [], rows: []
)
@organization_cache = {}
@seen_person_keys = {}
end

def call
ActiveRecord::Base.transaction do
each_row { |row, number| process_row(row, number) }
raise ActiveRecord::Rollback if @dry_run
end
@result
end

private

def each_row
sheet = open_spreadsheet
header = sheet.row(sheet.first_row).map { |label| HEADER_ALIASES[normalize_header(label)] }
number = 0
(sheet.first_row + 1..sheet.last_row).each do |index|
values = sheet.row(index)
next if values.all?(&:blank?)

number += 1
yield header.zip(values).to_h, number
end
end

def open_spreadsheet
Roo::Spreadsheet.open(@file_path, extension: @extension)
end

def normalize_header(label)
label.to_s.strip.downcase.gsub(/\s+/, " ")
end

def process_row(row, number)
@result.rows_processed += 1
first_name = clean(row[:first_name])
last_name = clean(row[:last_name])
email = clean(row[:email])&.downcase
organization_name = clean(row[:organization])

preview = RowPreview.new(
number: number, first_name: first_name, last_name: last_name,
email: email, organization_name: organization_name
)

missing = missing_required(first_name: first_name, last_name: last_name, email: email)
return skip(preview, "missing #{missing.join(", ")}") if missing.any?

key = "#{email} #{last_name.downcase}"
if @seen_person_keys.key?(key)
return skip(preview, "duplicate of row #{@seen_person_keys[key]} in this file")
end
@seen_person_keys[key] = number

person = resolve_person(first_name: first_name, last_name: last_name, email: email, preview: preview)
registration = resolve_registration(person, preview)
link_organization(registration, organization_name, preview)

@result.rows << preview
end

def missing_required(first_name:, last_name:, email:)
[
[ "first name", first_name ], [ "last name", last_name ], [ "email", email ]
].select { |_label, value| value.blank? }.map(&:first)
end

def resolve_person(first_name:, last_name:, email:, preview:)
existing = Person.where("LOWER(email) = ? AND LOWER(last_name) = ?", email, last_name.downcase).first
if existing
@result.people_matched += 1
preview.person_status = :matched
preview.person_label = existing.full_name
return existing
end

@result.people_created += 1
preview.person_status = :new
preview.person_label = "#{first_name} #{last_name}"
person = Person.new(first_name: first_name, last_name: last_name, email: email)
person.save! unless @dry_run
person
end

def resolve_registration(person, preview)
existing = @event.event_registrations.find_by(registrant: person) unless person.new_record?

if existing.nil?
@result.registrations_created += 1
preview.registration_status = :created
return existing if @dry_run

@event.event_registrations.create!(registrant: person, status: IMPORTED_STATUS)
elsif existing.attended?
@result.registrations_already_attended += 1
preview.registration_status = :already_attended
existing
else
@result.registrations_promoted += 1
preview.registration_status = :promoted
existing.update!(status: IMPORTED_STATUS) unless @dry_run
existing
end
end

def link_organization(registration, name, preview)
if name.blank?
preview.organization_status = :none
return
end

organization = find_organization(name)
unless organization
@result.organizations_unmatched += 1
preview.organization_status = :unmatched
return
end

@result.organizations_linked += 1
preview.organization_status = :linked
return if @dry_run || registration.nil?

registration.event_registration_organizations.find_or_create_by!(organization: organization)
end

def find_organization(name)
key = name.downcase
return @organization_cache[key] if @organization_cache.key?(key)

@organization_cache[key] = Organization.where("LOWER(name) = ?", key).first
end

def skip(preview, reason)
preview.skipped_reason = reason
@result.skipped << preview
@result.rows << preview
nil
end

def clean(value)
value.to_s.strip.presence
end
end
Loading