diff --git a/AGENTS.md b/AGENTS.md index d13247ff10..f295e5ca0f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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: ` 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`) diff --git a/Gemfile b/Gemfile index b94b5a131b..95acf40d96 100644 --- a/Gemfile +++ b/Gemfile @@ -118,3 +118,5 @@ gem "opentelemetry-instrumentation-all" # Error monitoring (production-only via initializer guard) gem "honeybadger", "~> 6.4" + +gem "roo", "~> 2.10" diff --git a/Gemfile.lock b/Gemfile.lock index d65c2cd922..23bcd75663 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -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) @@ -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 @@ -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 @@ -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 @@ -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 diff --git a/app/controllers/event_registration_imports_controller.rb b/app/controllers/event_registration_imports_controller.rb new file mode 100644 index 0000000000..57808adb23 --- /dev/null +++ b/app/controllers/event_registration_imports_controller.rb @@ -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 diff --git a/app/policies/event_registration_policy.rb b/app/policies/event_registration_policy.rb index 5030b3649e..f493c947bc 100644 --- a/app/policies/event_registration_policy.rb +++ b/app/policies/event_registration_policy.rb @@ -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? diff --git a/app/services/event_registration_importer.rb b/app/services/event_registration_importer.rb new file mode 100644 index 0000000000..9e0b58a353 --- /dev/null +++ b/app/services/event_registration_importer.rb @@ -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 diff --git a/app/views/event_registration_imports/create.html.erb b/app/views/event_registration_imports/create.html.erb new file mode 100644 index 0000000000..550498d661 --- /dev/null +++ b/app/views/event_registration_imports/create.html.erb @@ -0,0 +1,106 @@ +<% content_for(:page_bg_class, "admin-only bg-blue-100") %> +
+
+ <%= link_to "← Choose a different file", new_event_registration_import_path(event_id: @event.id), + class: "text-sm #{eyebrow_link_class} px-2 py-1" %> +
+ +

+ Preview import +

+

+ <%= @filename %><%= @event.time_title %>. + Nothing has been saved yet. +

+ +
+ <% [ + [ "Rows", @result.rows_processed ], + [ "New people", @result.people_created ], + [ "Matched people", @result.people_matched ], + [ "New registrations", @result.registrations_created ], + [ "Promoted", @result.registrations_promoted ], + [ "Skipped", @result.skipped.size ] + ].each do |label, count| %> +
+
<%= count %>
+
<%= label %>
+
+ <% end %> +
+ +
+
+ + + + + + + + + + + + + <% @result.rows.each do |row| %> + "> + + + + <% if row.skipped_reason %> + + <% else %> + + + + <% end %> + + <% end %> + +
#NameEmailPersonRegistrationOrganization
<%= row.number %><%= "#{row.first_name} #{row.last_name}".strip.presence || "—" %><%= row.email.presence || "—" %> + + Skipped — <%= row.skipped_reason %> + + + <% if row.person_status == :new %> + New person + <% else %> + Matched + <% end %> + + <% case row.registration_status %> + <% when :created %> + Will add as attended + <% when :promoted %> + Promote to attended + <% when :already_attended %> + Already attended + <% end %> + + <% case row.organization_status %> + <% when :linked %> + <%= row.organization_name %> + <% when :unmatched %> + + Not found: <%= row.organization_name %> + + <% else %> + + <% end %> +
+
+
+ +
+ <%= form_with url: confirm_event_registration_import_path, method: :post do |f| %> + <%= f.hidden_field :signed_id, value: @blob.signed_id %> + <%= f.hidden_field :event_id, value: @event.id %> + <%= f.hidden_field :extension, value: @extension %> +
+ <%= f.submit "Import #{@result.registrations_total} registrations", class: "btn btn-primary-outline bg-blue-100" %> + <%= link_to "Cancel", new_event_registration_import_path(event_id: @event.id), class: "btn btn-secondary-outline" %> +
+ <% end %> +
+
diff --git a/app/views/event_registration_imports/new.html.erb b/app/views/event_registration_imports/new.html.erb new file mode 100644 index 0000000000..3a54496942 --- /dev/null +++ b/app/views/event_registration_imports/new.html.erb @@ -0,0 +1,51 @@ +<% content_for(:page_bg_class, "admin-only bg-blue-100") %> +
+
+ <%= link_to "← Registrants", event_registrations_path(event_id: @event&.id, admin: true), + class: "text-sm #{eyebrow_link_class} px-2 py-1" %> +
+ +

+ Import registrants +

+

+ Upload a spreadsheet of people who attended a training and we'll add each one to the + event as an attended registration. People already in the portal are + matched by email and last name — no duplicates are created. You'll see a preview of + exactly what will happen before anything is saved. +

+

+ Expected columns: FirstName, + LastName, + Organization, + EMail. Accepts .xlsx, .csv, or .xls. +

+ +
+ <% if flash.now[:alert].present? %> +
+ <%= flash.now[:alert] %> +
+ <% end %> + + <%= form_with url: event_registration_import_path, method: :post, multipart: true do |f| %> +
+ <%= f.label :event_id, "Event", class: "block text-sm font-medium text-gray-700 mb-1" %> + <%= f.select :event_id, + options_from_collection_for_select(@events, :id, :time_title, @event&.id), + { prompt: "Choose an event…" }, + class: "block w-full text-sm text-gray-700 border border-gray-300 rounded-lg focus:border-blue-500 focus:ring-blue-200 p-2" %> +
+
+ <%= f.label :file, "Spreadsheet file", class: "block text-sm font-medium text-gray-700 mb-1" %> + <%= f.file_field :file, accept: ".xlsx,.csv,.xls", + class: "block w-full text-sm text-gray-700 border border-gray-300 rounded-lg cursor-pointer focus:outline-none p-2" %> +
+
+ <%= f.submit "Preview import", class: "btn btn-primary-outline bg-blue-100" %> + <%= link_to "Cancel", event_registrations_path(event_id: @event&.id, admin: true), + class: "btn btn-secondary-outline" %> +
+ <% end %> +
+
diff --git a/app/views/event_registrations/index.html.erb b/app/views/event_registrations/index.html.erb index 677b5f9600..5e66aea64c 100644 --- a/app/views/event_registrations/index.html.erb +++ b/app/views/event_registrations/index.html.erb @@ -1,8 +1,8 @@ <% content_for(:page_bg_class, "admin-only bg-blue-100") %> -
+
-
+

<%= EventRegistration.model_name.human.pluralize %> (<%= @event_registrations_count %>)

@@ -18,6 +18,11 @@ event_registrations_path(request.query_parameters.merge(format: :csv)), class: "text-sm #{eyebrow_link_class} px-2 py-1" %> <% end %> + <% if allowed_to?(:import?, EventRegistration) %> + <%= link_to "Import registrants", + new_event_registration_import_path(event_id: @filtered_event&.id), + class: "admin-only bg-blue-100 btn btn-secondary-outline" %> + <% end %> <% if allowed_to?(:new?, EventRegistration) %> <%= link_to "New #{EventRegistration.model_name.human.downcase}", new_event_registration_path(return_to: "index"), @@ -45,8 +50,8 @@ <% unless @filtered_event %> Event <% end %> - Date registered - Actions + Date registered + Actions @@ -56,19 +61,19 @@ <% events_with_forms = EventForm.where(event_id: event_ids).distinct.pluck(:event_id).to_set %> <% @event_registrations.each do |event_registration| %> - - + +
<%= person_profile_button(event_registration.registrant, compact: true) %> <% if submitted_pairs.include?([ event_registration.registrant_id, event_registration.event_id ]) %> - + <% elsif events_with_forms.include?(event_registration.event_id) %> - + <% end %>
<% unless @filtered_event %> - + <%= event_show_button(event_registration.event, truncate_at: 50, compact: true, @@ -77,7 +82,7 @@ subtitle: event_registration.event.decorate.short_date_range) %> <% end %> - + <%= event_registration.created_at.strftime("%B %d, %Y") %> @@ -97,7 +102,7 @@ <% unless @event_registrations.any? %> -

No <%= EventRegistration.model_name.human.pluralize.downcase %> found.

+

No <%= EventRegistration.model_name.human.pluralize.downcase %> found.

<% end %> diff --git a/config/features.yml b/config/features.yml index f2093bf757..c98b136f3d 100644 --- a/config/features.yml +++ b/config/features.yml @@ -2283,6 +2283,22 @@ - "Only mutations show: opening or printing a record never clutters its log." - "Deleting a child or attachment is recorded too — the entry keeps the name even after the file is gone." +- name: "Import registrants from a spreadsheet" + area: registration + display_status: admin_facing + released_on: 2026-08-25 + action_path: "/event_registrations/import" + summary: >- + Bulk-add attended registrants to an event from an .xlsx or .csv roster. Pick the + event, upload the file, and every row is added as an attended registration — + people already in the portal are matched by email and last name, so no duplicates + are created. + pro_tips: + - "Expected columns: FirstName, LastName, Organization, EMail." + - "You get a full preview — new vs. matched people, new vs. promoted registrations, unmatched organizations — before anything is saved." + - "Organizations are matched by name and linked to the registration, but never created; an unrecognized name is flagged, not invented." + - "Reach it from the \"Import registrants\" button on the registrants page." + - name: "Staff taggings directory" area: people display_status: admin_facing diff --git a/config/routes.rb b/config/routes.rb index 2b096cbe06..1b836ca991 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -107,6 +107,10 @@ post "registration/:slug/cancel", to: "events/registrations#cancel", as: :registration_cancel post "registration/:slug/reactivate", to: "events/registrations#reactivate", as: :registration_reactivate post "registration/:slug/pay", to: "events/registrations#pay", as: :registration_pay + resource :event_registration_import, only: %i[new create], path: "event_registrations/import", + controller: "event_registration_imports" do + post :confirm + end resources :event_registrations do member do get :confirm diff --git a/spec/fixtures/files/event_registrants_import.csv b/spec/fixtures/files/event_registrants_import.csv new file mode 100644 index 0000000000..05f91473fb --- /dev/null +++ b/spec/fixtures/files/event_registrants_import.csv @@ -0,0 +1,6 @@ +FirstName,LastName,Organization,EMail +Cidney,Acosta,A New Leaf,cacosta@turnanewleaf.org +Mariel,Acosta,Unknown Org LLC,macosta@wrcnbc.org +Kaela,Adams,A New Leaf,kadams@rcoe.us +Cidney,Acosta,A New Leaf,cacosta@turnanewleaf.org +Angel,,A New Leaf,angel.agudo@alvordschools.org diff --git a/spec/requests/event_registration_imports_spec.rb b/spec/requests/event_registration_imports_spec.rb new file mode 100644 index 0000000000..150cdeb78e --- /dev/null +++ b/spec/requests/event_registration_imports_spec.rb @@ -0,0 +1,101 @@ +require "rails_helper" + +RSpec.describe "Event registration imports", type: :request do + let(:admin) { create(:user, :admin) } + let(:regular_user) { create(:user) } + let(:event) { create(:event) } + + before { create(:organization, name: "A New Leaf") } + + let(:csv) { fixture_file_upload("spec/fixtures/files/event_registrants_import.csv", "text/csv") } + + def signed_blob + ActiveStorage::Blob.create_and_upload!( + io: Rails.root.join("spec/fixtures/files/event_registrants_import.csv").open, + filename: "event_registrants_import.csv", + content_type: "text/csv" + ).signed_id + end + + describe "GET /event_registrations/import/new" do + it "renders the upload form for admins" do + sign_in admin + get new_event_registration_import_path + + expect(response).to be_successful + expect(response.body).to include("Import registrants") + end + + it "redirects non-admins" do + sign_in regular_user + get new_event_registration_import_path + + expect(response).to redirect_to(root_path) + end + end + + describe "POST /event_registrations/import" do + before { sign_in admin } + + it "renders a preview without writing anything" do + expect { + post event_registration_import_path, params: { event_id: event.id, file: csv } + }.not_to change(Person, :count) + + expect(response).to have_http_status(:unprocessable_content) + expect(response.body).to include("Preview import") + end + + it "re-renders the form when no event is chosen" do + post event_registration_import_path, params: { file: csv } + + expect(response).to have_http_status(:unprocessable_content) + expect(response.body).to include("Choose an event") + end + + it "re-renders the form when no file is chosen" do + post event_registration_import_path, params: { event_id: event.id } + + expect(response).to have_http_status(:unprocessable_content) + expect(response.body).to include("Choose a spreadsheet file") + end + + it "is forbidden for non-admins" do + sign_in regular_user + post event_registration_import_path, params: { event_id: event.id, file: csv } + + expect(response).to redirect_to(root_path) + end + end + + describe "POST /event_registrations/import/confirm" do + before { sign_in admin } + + it "creates attended registrations and redirects with a notice" do + expect { + post confirm_event_registration_import_path, + params: { signed_id: signed_blob, event_id: event.id, extension: "csv" } + }.to change(Person, :count).by(3).and change { event.event_registrations.count }.by(3) + + expect(event.event_registrations.pluck(:status).uniq).to eq([ "attended" ]) + expect(response).to redirect_to(event_registrations_path(event_id: event.id, admin: true)) + expect(flash[:notice]).to match(/3 attended registrations created/) + end + + it "redirects with an alert when the upload is gone" do + post confirm_event_registration_import_path, + params: { signed_id: "bogus", event_id: event.id, extension: "csv" } + + expect(response).to redirect_to(new_event_registration_import_path) + expect(flash[:alert]).to match(/no longer available/i) + end + + it "is forbidden for non-admins" do + sign_in regular_user + post confirm_event_registration_import_path, + params: { signed_id: signed_blob, event_id: event.id, extension: "csv" } + + expect(response).to redirect_to(root_path) + end + end +end diff --git a/spec/services/event_registration_importer_spec.rb b/spec/services/event_registration_importer_spec.rb new file mode 100644 index 0000000000..aab66870f3 --- /dev/null +++ b/spec/services/event_registration_importer_spec.rb @@ -0,0 +1,108 @@ +require "rails_helper" + +RSpec.describe EventRegistrationImporter do + let(:event) { create(:event) } + let(:csv_path) { Rails.root.join("spec/fixtures/files/event_registrants_import.csv").to_s } + + before { create(:organization, name: "A New Leaf") } + + def import(dry_run:) + described_class.call(file_path: csv_path, event: event, extension: "csv", dry_run: dry_run) + end + + describe "counting (dry run)" do + subject(:result) { import(dry_run: true) } + + it "processes every non-blank row" do + expect(result.rows_processed).to eq(5) + end + + it "counts a would-be-created person for each unmatched row" do + expect(result.people_created).to eq(3) + end + + it "skips the duplicate and the incomplete rows" do + expect(result.skipped.size).to eq(2) + end + + it "flags the row missing a last name as skipped" do + missing = result.rows.find { |r| r.skipped_reason&.include?("last name") } + expect(missing).to be_present + end + + it "flags a repeated person as a duplicate of the earlier row" do + duplicate = result.rows.find { |r| r.skipped_reason&.include?("duplicate") } + expect(duplicate).to be_present + expect(duplicate.skipped_reason).to match(/row 1/) + end + + it "matches an organization that exists and reports the unknown one" do + expect(result.organizations_linked).to eq(2) + expect(result.organizations_unmatched).to eq(1) + end + + it "writes nothing" do + expect { import(dry_run: true) }.not_to change(Person, :count) + expect { import(dry_run: true) }.not_to change(EventRegistration, :count) + end + end + + describe "matching an existing person" do + subject(:result) { import(dry_run: true) } + + let!(:existing) do + create(:person, first_name: "Cid", last_name: "Acosta", email: "cacosta@turnanewleaf.org") + end + + it "reuses the person instead of counting a new one" do + expect(result.people_matched).to eq(1) + expect(result.people_created).to eq(2) + end + end + + describe "the real import" do + it "creates people and attended registrations" do + expect { import(dry_run: false) } + .to change(Person, :count).by(3) + .and change { event.event_registrations.count }.by(3) + + expect(event.event_registrations.pluck(:status).uniq).to eq([ "attended" ]) + end + + it "links matched organizations to the registration" do + import(dry_run: false) + person = Person.find_by(email: "cacosta@turnanewleaf.org") + registration = event.event_registrations.find_by(registrant: person) + expect(registration.organizations.pluck(:name)).to eq([ "A New Leaf" ]) + end + + it "does not create the unmatched organization" do + expect { import(dry_run: false) }.not_to change { Organization.where(name: "Unknown Org LLC").count } + end + + it "does not duplicate a person already in the system" do + create(:person, first_name: "Cidney", last_name: "Acosta", email: "cacosta@turnanewleaf.org") + expect { import(dry_run: false) }.to change(Person, :count).by(2) + end + + it "promotes an existing non-attended registration to attended" do + person = create(:person, last_name: "Acosta", email: "cacosta@turnanewleaf.org") + registration = create(:event_registration, event: event, registrant: person, status: "registered") + + result = import(dry_run: false) + + expect(registration.reload.status).to eq("attended") + expect(result.registrations_promoted).to eq(1) + end + + it "leaves an already-attended registration untouched and counts it" do + person = create(:person, last_name: "Acosta", email: "cacosta@turnanewleaf.org") + create(:event_registration, event: event, registrant: person, status: "attended") + + result = import(dry_run: false) + + expect(result.registrations_already_attended).to eq(1) + expect(result.registrations_created).to eq(2) + end + end +end diff --git a/spec/views/page_bg_class_alignment_spec.rb b/spec/views/page_bg_class_alignment_spec.rb index 6c4cd5ca63..948bf5b1cc 100644 --- a/spec/views/page_bg_class_alignment_spec.rb +++ b/spec/views/page_bg_class_alignment_spec.rb @@ -106,6 +106,8 @@ "app/views/story_share_admin/show.html.erb" => "admin-only bg-blue-100", "app/views/story_imports/new.html.erb" => "admin-only bg-blue-100", "app/views/story_imports/create.html.erb" => "admin-only bg-blue-100", + "app/views/event_registration_imports/new.html.erb" => "admin-only bg-blue-100", + "app/views/event_registration_imports/create.html.erb" => "admin-only bg-blue-100", # index "app/views/allocations/index.html.erb" => "admin-only bg-blue-100", "app/views/other_responses/index.html.erb" => "admin-only bg-blue-100",