diff --git a/AGENTS.md b/AGENTS.md index df764e7b9f..8f124370fe 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -49,7 +49,7 @@ This codebase (Rails 8.1) | Directory | Purpose | Count | |---|---|---| | `app/models/` | ActiveRecord models | ~78 files | -| `app/services/` | Service objects and POROs (e.g. `MoneyFormatter` for currency display) | ~28 files | +| `app/services/` | Service objects and POROs (e.g. `MoneyFormatter` for currency display) | ~29 files | | `app/jobs/` | SolidQueue background jobs | 3 files | | `app/models/concerns/` | Shared model modules | 15 concerns | @@ -57,7 +57,7 @@ This codebase (Rails 8.1) | Directory | Purpose | Count | |---|---|---| -| `app/controllers/` | Rails controllers (admin/, events/) | ~70 files | +| `app/controllers/` | Rails controllers (admin/, events/) | ~74 files | | `app/views/` | ERB templates | ~504 files | | `app/decorators/` | Draper decorators for view logic | ~38 files | | `app/policies/` | ActionPolicy authorization rules | ~49 files | @@ -190,6 +190,7 @@ end - `FormBuilderService` — Builds configurable forms from composable sections with per-field visibility - `ModelDeduper` — Deduplication logic - `RichTextMigrator` — Rich text migration utility +- `StoryImporter` — Imports stories from a WordPress Posts Export CSV (idea per row, connected published Story for published rows) - `DisplayImagePresenter` — Image display logic - `ScholarshipsGrouping` (presenter) — Groups scholarships into the index's funder → grant → recipient hierarchy; grant-free awards collect under a trailing "Unfunded" group @@ -338,12 +339,12 @@ Custom colors defined in `app/frontend/stylesheets/application.tailwind.css`: |---|---|---| | `spec/models/` | ~58 | Model unit tests | | `spec/views/` | ~73 | View template tests | -| `spec/requests/` | ~47 | HTTP request/integration tests | +| `spec/requests/` | ~68 | HTTP request/integration tests | | `spec/system/` | ~25 | End-to-end browser tests (Capybara) | | `spec/routing/` | ~13 | Route definition tests | | `spec/policies/` | ~9 | Authorization policy tests | | `spec/decorators/` | ~10 | Decorator tests | -| `spec/services/` | ~12 | Service object tests | +| `spec/services/` | ~17 | Service object tests | | `spec/mailers/` | ~5 | Mailer tests | | `spec/helpers/` | ~1 | Helper tests | | `spec/factories/` | ~53 | FactoryBot factory definitions | @@ -419,8 +420,13 @@ RuboCop linting on PRs and pushes to main. ## Rake Tasks -Located in `lib/tasks/` (4 files): +Located in `lib/tasks/` (9 files): - `dev.rake` — Development database seeding from XML/CSV - `rhino_migrator.rake` — Rich text editor migration - `attachment_report.rake` — Attachment reporting - `migrate_internal_id_to_filemaker_code.rake` — FileMaker code migration +- `import_stories.rake` — Imports stories from a WordPress Posts Export CSV (`StoryImporter`) +- `convert_age_ranges.rake` — Age range conversion +- `legacy_user_permissions_to_comments.rake` — Migrate legacy user permissions into comments +- `migrate_workshop_logs.rake` — Workshop log migration +- `migrate_sectors.rake` — Sector data migration diff --git a/app/controllers/story_imports_controller.rb b/app/controllers/story_imports_controller.rb new file mode 100644 index 0000000000..9de41d290d --- /dev/null +++ b/app/controllers/story_imports_controller.rb @@ -0,0 +1,63 @@ +class StoryImportsController < ApplicationController + # Admin-only flow for importing stories from a WordPress Posts Export CSV. + # + # new → upload form + # create → dry-run preview of what would be created (nothing written) + # confirm → run the real import against the stashed file + # + # The uploaded CSV is stashed as an ActiveStorage blob between the preview + # and confirm steps (it is too large for the cookie session). The blob is + # purged once the import runs. + + def new + authorize! Story, to: :import? + end + + def create + authorize! Story, to: :import? + + file = params[:file] + return redirect_to(new_story_import_path, alert: "Choose a CSV file to import.") if file.blank? + return redirect_to(new_story_import_path, alert: "That file is not a CSV.") unless csv?(file) + + @filename = file.original_filename + @blob = ActiveStorage::Blob.create_and_upload!( + io: file.open, filename: @filename, content_type: "text/csv" + ) + @result = run_import(file.path, dry_run: true) + render :create + rescue CSV::MalformedCSVError, ArgumentError => e + @blob&.purge + redirect_to new_story_import_path, alert: "Could not read that CSV: #{e.message}" + end + + def confirm + authorize! Story, to: :import? + + blob = ActiveStorage::Blob.find_signed!(params[:signed_id]) + result = blob.open { |tempfile| run_import(tempfile.path, dry_run: false) } + blob.purge + + redirect_to stories_path, notice: import_notice(result) + rescue ActiveSupport::MessageVerifier::InvalidSignature, ActiveRecord::RecordNotFound + redirect_to new_story_import_path, + alert: "That upload is no longer available — please choose the file again." + end + + private + + def run_import(path, dry_run:) + StoryImporter.new(csv_path: path, import_user: current_user, dry_run: dry_run).call + end + + def csv?(file) + file.original_filename.to_s.downcase.end_with?(".csv") || + file.content_type.to_s.in?(%w[text/csv application/csv application/vnd.ms-excel]) + end + + def import_notice(result) + "Import complete — #{result.ideas_created} story ideas and " \ + "#{result.stories_created} connected stories created" \ + "#{" (#{result.skipped.size} rows skipped)" if result.skipped.any?}." + end +end diff --git a/app/policies/story_policy.rb b/app/policies/story_policy.rb index 960f6d4e20..5677ca300a 100644 --- a/app/policies/story_policy.rb +++ b/app/policies/story_policy.rb @@ -9,6 +9,11 @@ def show? admin? || record.publicly_visible? || (authenticated? && record.published?) end + # Bulk import from a WordPress export CSV — admins only. + def import? + admin? + end + # Scoping # See https://actionpolicy.evilmartians.io/#/scoping # diff --git a/app/services/story_importer.rb b/app/services/story_importer.rb new file mode 100644 index 0000000000..75b0acb7b2 --- /dev/null +++ b/app/services/story_importer.rb @@ -0,0 +1,291 @@ +# frozen_string_literal: true + +require "csv" + +# Imports stories from a WordPress "Posts Export" CSV (stories.awbw.org). +# +# For EVERY row we create a StoryIdea (the canonical submission record). For +# rows that were published/public on WordPress (Status == "publish") we ALSO +# create a published Story connected back to that idea via story_idea_id — +# mirroring the in-app flow where staff promote an idea into a published story. +# +# This importer deliberately requires NO schema changes. Fields that have no +# home in the current model (original WordPress publish date, the source +# permalink / post ID, the free-text US state, the free-text facilitator) are +# logged as warnings rather than dropped silently. See the importer notes in +# the PR / .context for the recommended (optional) columns that would let us +# preserve them. +class StoryImporter + Result = Struct.new( + :rows_processed, :ideas_created, :stories_created, :skipped, :warnings, + keyword_init: true + ) do + def summary + [ + "rows processed: #{rows_processed}", + "story ideas created: #{ideas_created}", + "connected stories created: #{stories_created}", + "skipped: #{skipped.size}", + "warnings: #{warnings.size}" + ].join("\n") + end + end + + # WordPress "publish" status means the post was live and public. + PUBLISHED_WP_STATUS = "publish" + + # WordPress "Categories" values that map onto an existing Sector. Unmatched + # values (e.g. the thematic "Self-Care & Personal Growth") are logged — the + # app has no "StoryCategory" taxonomy seeded to receive them yet. + SECTOR_SYNONYMS = { + "domestic violence" => "Domestic Violence", + "foster care" => "Foster Care/Adoption", + "mental health" => "Mental Health", + "incarceration" => "Incarceration", + "substance abuse recovery" => "Substance Use", + "homelessness" => "Homeless", + "lgbtqia+" => "LGBTQIA+", + "child abuse / neglect" => "Child Abuse", + "sexual assault" => "Sexual Assault", + "community violence" => "Community Oppression/Violence", + "disability services" => "Disability", + "immigration" => "Immigration", + "schools & universities" => "Education/Schools", + "social justice" => "Restorative/Transformative Justice" + }.freeze + + # WordPress "showhide_the_name" → our author_credit_preference enum. + AUTHOR_CREDIT_BY_SHOWHIDE = { + "display_full_name" => "full_name", + "display_first_name" => "first_name_only", + "hide_full_name" => "anonymous" + }.freeze + DEFAULT_AUTHOR_CREDIT = "full_name" + + # WordPress audience "Tags" → our WindowsType short_name. + CHILD_AUDIENCE = %w[children teens].freeze + COMBINED_AUDIENCE = %w[families].freeze + DEFAULT_WINDOWS_TYPE = "Adult" + + # Flags across the various WordPress "featured" custom fields. + FEATURED_FLAGS = %w[ + home_top_featured_story home_bottom_featured_story + new_home_top_featured new_home_bottom_featured mark_as_featured + ].freeze + + def initialize(csv_path:, import_user:, organization_status: nil, dry_run: false, logger: nil) + @csv_path = csv_path + @import_user = import_user + @organization_status = organization_status || default_organization_status + @dry_run = dry_run + @logger = logger || Rails.logger + @result = Result.new( + rows_processed: 0, ideas_created: 0, stories_created: 0, skipped: [], warnings: [] + ) + @organization_cache = {} + @windows_type_cache = {} + end + + def call + raise ArgumentError, "import_user is required" if @import_user.nil? + raise ArgumentError, "no OrganizationStatus available" if @organization_status.nil? + + CSV.foreach(@csv_path, headers: true, encoding: "bom|utf-8") do |row| + @result.rows_processed += 1 + begin + import_row(row) + rescue => e + record_skip(row, "#{e.class}: #{e.message}") + @logger.error("[StoryImporter] row #{wp_id(row)}: #{e.class} - #{e.message}") + end + end + @result + end + + private + + attr_reader :result + + def import_row(row) + title = clean(row["Title"]) + return record_skip(row, "blank title") if title.blank? + + organization = find_or_create_organization(row) + windows_type = windows_type_for(row) + note_unmapped_fields(row) + + idea = build_idea(row, title:, organization:, windows_type:) + return record_skip(row, "story idea already exists for this org/title") if duplicate_idea?(idea) + + return unless persist(idea) + tag_taxonomies(idea, row) + @result.ideas_created += 1 + + return unless published?(row) + create_connected_story(row, idea:, title:, organization:, windows_type:) + end + + def create_connected_story(row, idea:, title:, organization:, windows_type:) + if Story.where("LOWER(title) = ?", title.downcase).exists? + return record_warning(row, "published story skipped — title already taken: #{title.inspect}") + end + + featured = FEATURED_FLAGS.any? { |flag| truthy?(row[flag]) } + story = Story.new( + story_idea: idea, + title: title, + rhino_body: body_html(row, title), + organization: organization, + windows_type: windows_type, + external_workshop_title: clean(row["story_workshop_name"]), + youtube_url: youtube_url(row), + author_credit_preference: author_credit(row), + permission_given: true, + published: true, + publicly_visible: true, + featured: featured, + publicly_featured: featured, + created_by: @import_user, + updated_by: @import_user + ) + return unless persist(story) + tag_taxonomies(story, row) + @result.stories_created += 1 + end + + def build_idea(row, title:, organization:, windows_type:) + StoryIdea.new( + title: title, + rhino_body: body_html(row, title), + organization: organization, + windows_type: windows_type, + external_workshop_title: clean(row["story_workshop_name"]), + youtube_url: youtube_url(row), + author_credit_preference: author_credit(row), + permission_given: true, + created_by: @import_user, + updated_by: @import_user + ) + end + + def duplicate_idea?(idea) + StoryIdea.where(organization_id: idea.organization_id) + .where("LOWER(title) = ?", idea.title.downcase) + .exists? + end + + # Best-effort: tag whatever WordPress categories resolve to an existing + # Sector. Unmatched values are surfaced as warnings, never invented. + def tag_taxonomies(record, row) + return if @dry_run || record.new_record? + + name = clean(row["Categories"]) + return if name.blank? || name.casecmp?("uncategorized") + + sector_name = SECTOR_SYNONYMS[name.downcase] || name + sector = Sector.where("LOWER(name) = ?", sector_name.downcase).first + if sector + record.sectors |= [ sector ] + else + record_warning(row, "no Sector match for category #{name.inspect}") + end + end + + def find_or_create_organization(row) + name = clean(row["organization_name"]).presence || "Unknown organization" + @organization_cache[name.downcase] ||= + Organization.where("LOWER(name) = ?", name.downcase).first || + create_organization(name) + end + + def create_organization(name) + return Organization.new(name: name, organization_status: @organization_status) if @dry_run + Organization.create!(name: name, organization_status: @organization_status) + end + + def windows_type_for(row) + audiences = clean(row["Tags"]).to_s.downcase.split(",").map(&:strip) + short_name = + if audiences.intersect?(COMBINED_AUDIENCE) || + (audiences.intersect?(CHILD_AUDIENCE) && (audiences - CHILD_AUDIENCE).any?) + "Combined" + elsif audiences.intersect?(CHILD_AUDIENCE) + "Children" + else + DEFAULT_WINDOWS_TYPE + end + @windows_type_cache[short_name] ||= WindowsType.find_by!(short_name: short_name) + end + + # Records that have no home in the current schema — logged, not dropped. + def note_unmapped_fields(row) + state = clean(row["state"]) + record_warning(row, "unmapped state #{state.inspect}") if state.present? + + facilitator = [ clean(row["facilitator_name"]), clean(row["facilitator_last_name"]) ] + .compact_blank.join(" ") + record_warning(row, "unmapped facilitator #{facilitator.inspect}") if facilitator.present? + + date = clean(row["Date"]) + record_warning(row, "unmapped original publish date #{date.inspect}") if date.present? + end + + def body_html(row, title) + clean_html(row["Content"]).presence || + clean_html(row["Excerpt"]).presence || + "

#{ERB::Util.html_escape(title)}

" + end + + def youtube_url(row) + (clean(row["story_youtube_url"]).presence || clean(row["story_youtube_ur"]).presence) + end + + def author_credit(row) + AUTHOR_CREDIT_BY_SHOWHIDE[clean(row["showhide_the_name"])] || DEFAULT_AUTHOR_CREDIT + end + + def published?(row) + clean(row["Status"]).casecmp?(PUBLISHED_WP_STATUS) + end + + def persist(record) + return true if @dry_run + record.save! + true + rescue ActiveRecord::RecordInvalid => e + @logger.error("[StoryImporter] invalid #{record.class}: #{e.message}") + false + end + + def default_organization_status + OrganizationStatus.find_by(name: "Pending") || OrganizationStatus.first + end + + def wp_id(row) + clean(row["ID"]).presence || "?" + end + + def truthy?(value) + %w[1 true yes].include?(clean(value).to_s.downcase) + end + + # WordPress export encodes entities (e.g. &) even in plain-text columns. + def clean(value) + CGI.unescapeHTML(value.to_s).strip + end + + # Content columns are already HTML; only decode double-encoded entities. + def clean_html(value) + value.to_s.strip + end + + def record_skip(row, reason) + @result.skipped << "row #{wp_id(row)} (#{clean(row['Title'])}): #{reason}" + nil + end + + def record_warning(row, reason) + @result.warnings << "row #{wp_id(row)} (#{clean(row['Title'])}): #{reason}" + nil + end +end diff --git a/app/views/stories/index.html.erb b/app/views/stories/index.html.erb index 6c17afc974..87af5655a5 100644 --- a/app/views/stories/index.html.erb +++ b/app/views/stories/index.html.erb @@ -9,7 +9,12 @@

Check out stories from Windows Facilitators around the world and the healing they make possible through art

-
+
+ <% if allowed_to?(:import?, Story) %> + <%= link_to "Import", + new_story_import_path, + class: "admin-only bg-blue-100 btn btn-primary-outline" %> + <% end %> <% if allowed_to?(:new?, Story) %> <%= link_to "New Story", new_story_path, diff --git a/app/views/story_imports/create.html.erb b/app/views/story_imports/create.html.erb new file mode 100644 index 0000000000..6d2622a327 --- /dev/null +++ b/app/views/story_imports/create.html.erb @@ -0,0 +1,73 @@ +<% content_for(:page_bg_class, "admin-only bg-blue-100") %> +
+
+ <%= link_to "← Choose a different file", new_story_import_path, + class: "text-sm text-gray-500 hover:text-gray-700 px-2 py-1" %> +
+ +

+ Preview import +

+

+ Reviewing <%= @filename %>. + Nothing has been saved yet — confirm below to run the import. +

+ +
+ <% [ + [ "Rows read", @result.rows_processed ], + [ "Story ideas", @result.ideas_created ], + [ "Connected stories", @result.stories_created ], + [ "Rows skipped", @result.skipped.size ] + ].each do |label, value| %> +
+
<%= value %>
+
<%= label %>
+
+ <% end %> +
+ + <% if @result.skipped.any? %> +
+ + <%= pluralize(@result.skipped.size, "skipped row") %> + +
    + <% @result.skipped.first(100).each do |line| %> +
  • <%= line %>
  • + <% end %> +
+
+ <% end %> + + <% if @result.warnings.any? %> +
+ + <%= pluralize(@result.warnings.size, "warning") %> — fields with no home in the data model + +
    + <% @result.warnings.first(100).each do |line| %> +
  • <%= line %>
  • + <% end %> +
+ <% if @result.warnings.size > 100 %> +

…and <%= @result.warnings.size - 100 %> more.

+ <% end %> +
+ <% end %> + +
+ <%= form_with url: confirm_story_import_path, method: :post do |f| %> + <%= f.hidden_field :signed_id, value: @blob.signed_id %> +

+ This will create <%= pluralize(@result.ideas_created, "story idea") %> + and <%= pluralize(@result.stories_created, "connected story") %>. +

+
+ <%= f.submit "Confirm import", class: "btn btn-primary-outline bg-blue-100", + data: { turbo_confirm: "Create #{@result.ideas_created} story ideas and #{@result.stories_created} stories?" } %> + <%= link_to "Cancel", stories_path, class: "btn btn-secondary-outline" %> +
+ <% end %> +
+
diff --git a/app/views/story_imports/new.html.erb b/app/views/story_imports/new.html.erb new file mode 100644 index 0000000000..75162393e3 --- /dev/null +++ b/app/views/story_imports/new.html.erb @@ -0,0 +1,30 @@ +<% content_for(:page_bg_class, "admin-only bg-blue-100") %> +
+
+ <%= link_to "← Stories", stories_path, + class: "text-sm text-gray-500 hover:text-gray-700 px-2 py-1" %> +
+ +

+ Import stories from CSV +

+

+ Upload a WordPress Posts Export CSV. We'll create a story idea for every row and a + connected, published story for rows that were public on WordPress. You'll see a + preview of exactly what will be created before anything is saved. +

+ +
+ <%= form_with url: story_import_path, method: :post, multipart: true do |f| %> +
+ <%= f.label :file, "CSV file", class: "block text-sm font-medium text-gray-700 mb-1" %> + <%= f.file_field :file, accept: ".csv,text/csv", + 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", stories_path, class: "btn btn-secondary-outline" %> +
+ <% end %> +
+
diff --git a/config/routes.rb b/config/routes.rb index 529c0cc97e..d74bfa46fa 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -229,6 +229,10 @@ end get "search/:model", to: "search#index" resources :story_ideas + resource :story_import, only: %i[new create], path: "stories/import", + controller: "story_imports" do + post :confirm + end resources :stories resources :story_shares, only: [ :index, :show ] resources :video_recordings diff --git a/lib/tasks/import_stories.rake b/lib/tasks/import_stories.rake new file mode 100644 index 0000000000..f4eb484d93 --- /dev/null +++ b/lib/tasks/import_stories.rake @@ -0,0 +1,38 @@ +namespace :import do + desc "Import stories from a WordPress Posts Export CSV. " \ + "Usage: rake 'import:stories[path/to/export.csv,importer@awbw.org]' " \ + "(append ,dry to validate without writing)" + task :stories, [ :path, :user_email, :dry ] => :environment do |_task, args| + path = args[:path] + abort "Provide a CSV path: rake 'import:stories[path,user_email]'" if path.blank? + abort "CSV not found at #{path}" unless File.exist?(path) + + user = + if args[:user_email].present? + User.find_by!(email: args[:user_email]) + else + abort "Provide the importing user's email: rake 'import:stories[path,user_email]'" + end + + dry_run = args[:dry].to_s.downcase.in?(%w[dry dry_run true]) + + puts "Importing #{path}" + puts "Importing as #{user.email} (id=#{user.id})" + puts "DRY RUN — nothing will be written" if dry_run + + result = StoryImporter.new(csv_path: path, import_user: user, dry_run: dry_run).call + + puts "\n#{result.summary}" + + if result.skipped.any? + puts "\nSkipped rows:" + result.skipped.each { |line| puts " - #{line}" } + end + + if result.warnings.any? + puts "\nWarnings (#{result.warnings.size}) — unmapped fields / loose matches:" + result.warnings.first(50).each { |line| puts " - #{line}" } + puts " …and #{result.warnings.size - 50} more" if result.warnings.size > 50 + end + end +end diff --git a/spec/fixtures/files/stories_import.csv b/spec/fixtures/files/stories_import.csv new file mode 100644 index 0000000000..d2e11cb33c --- /dev/null +++ b/spec/fixtures/files/stories_import.csv @@ -0,0 +1,4 @@ +ID,Title,Content,Status,organization_name,story_workshop_name,Tags,Categories,showhide_the_name,state,facilitator_name,Date +101,Healing through art,

A published story.

,publish,Test Org,Adult Windows Workshop,Adults,Domestic Violence,display_full_name,California,Jane,2021-07-11 10:07:53 +102,Finding my voice,

Another published story.

,publish,Test Org,Heart Stories,Children,Mental Health,hide_full_name,Florida,Maria,2022-03-02 09:00:00 +103,A work in progress,

A draft.

,draft,Test Org,,Adults,Self-Care & Personal Growth,display_first_name,Oregon,Sam,2023-01-15 12:00:00 diff --git a/spec/requests/story_imports_spec.rb b/spec/requests/story_imports_spec.rb new file mode 100644 index 0000000000..3c2de4914f --- /dev/null +++ b/spec/requests/story_imports_spec.rb @@ -0,0 +1,117 @@ +require "rails_helper" + +RSpec.describe "Story imports", type: :request do + let(:admin) { create(:user, :admin) } + let(:regular_user) { create(:user) } + + # Reference data the importer needs to resolve rows. + before do + create(:windows_type, :adult) + create(:windows_type, :children) + create(:windows_type, :combined) + create(:organization_status, name: "Pending") + end + + let(:csv) { fixture_file_upload("spec/fixtures/files/stories_import.csv", "text/csv") } + + def signed_blob_for_fixture + ActiveStorage::Blob.create_and_upload!( + io: Rails.root.join("spec/fixtures/files/stories_import.csv").open, + filename: "stories_import.csv", + content_type: "text/csv" + ).signed_id + end + + describe "GET /stories/import/new" do + it "renders the upload form for admins" do + sign_in admin + get new_story_import_path + + expect(response).to be_successful + expect(response.body).to include("Import stories from CSV") + end + + it "redirects non-admins" do + sign_in regular_user + get new_story_import_path + + expect(response).to redirect_to(root_path) + end + + it "redirects guests to sign in" do + get new_story_import_path + + expect(response).to redirect_to(new_user_session_path) + end + end + + describe "POST /stories/import (preview)" do + before { sign_in admin } + + it "shows a dry-run preview without creating records" do + post story_import_path, params: { file: csv } + + expect(StoryIdea.count).to eq(0) + expect(Story.count).to eq(0) + expect(response).to be_successful + expect(response.body).to include("Preview import") + expect(response.body).to include("Connected stories") + end + + it "stashes the file as a blob so it can be confirmed" do + expect { + post story_import_path, params: { file: csv } + }.to change(ActiveStorage::Blob, :count).by(1) + end + + it "redirects with an alert when no file is given" do + post story_import_path, params: {} + + expect(response).to redirect_to(new_story_import_path) + expect(flash[:alert]).to match(/choose a csv/i) + end + + it "rejects non-CSV uploads" do + upload = fixture_file_upload("spec/fixtures/files/stories_import.csv", "image/png") + allow(upload).to receive(:original_filename).and_return("avatar.png") + + post story_import_path, params: { file: upload } + + expect(response).to redirect_to(new_story_import_path) + expect(flash[:alert]).to match(/not a csv/i) + end + + it "is forbidden for non-admins" do + sign_in regular_user + post story_import_path, params: { file: csv } + + expect(response).to redirect_to(root_path) + end + end + + describe "POST /stories/import/confirm" do + before { sign_in admin } + + it "creates ideas for every row and stories for published rows" do + expect { + post confirm_story_import_path, params: { signed_id: signed_blob_for_fixture } + }.to change(StoryIdea, :count).by(3).and change(Story, :count).by(2) + + expect(response).to redirect_to(stories_path) + expect(flash[:notice]).to match(/3 story ideas and 2 connected stories/) + end + + it "records the importing admin as the creator" do + post confirm_story_import_path, params: { signed_id: signed_blob_for_fixture } + + expect(Story.last.created_by).to eq(admin) + end + + it "redirects with an alert when the upload is gone" do + post confirm_story_import_path, params: { signed_id: "bogus" } + + expect(response).to redirect_to(new_story_import_path) + expect(flash[:alert]).to match(/no longer available/i) + end + end +end diff --git a/spec/services/story_importer_spec.rb b/spec/services/story_importer_spec.rb new file mode 100644 index 0000000000..f42217012f --- /dev/null +++ b/spec/services/story_importer_spec.rb @@ -0,0 +1,161 @@ +require "rails_helper" +require "csv" + +RSpec.describe StoryImporter do + let(:import_user) { create(:user) } + let!(:pending_status) { create(:organization_status, name: "Pending") } + let!(:adult_wt) { create(:windows_type, :adult) } + let!(:children_wt) { create(:windows_type, :children) } + let!(:combined_wt) { create(:windows_type, :combined) } + + # Minimal subset of the WordPress export columns the importer reads. + let(:headers) do + %w[ + ID Title Content Excerpt Status organization_name story_workshop_name + story_youtube_url showhide_the_name Tags Categories state + facilitator_name facilitator_last_name home_top_featured_story Date + ] + end + + # Held in an array so the Tempfiles aren't garbage-collected (and deleted) + # before the importer reads them. + let(:tempfiles) { [] } + + def csv_file(rows) + file = Tempfile.new([ "stories", ".csv" ]) + tempfiles << file + CSV.open(file.path, "w", write_headers: true, headers: headers) do |csv| + rows.each { |row| csv << headers.map { |h| row[h] } } + end + file.path + end + + def base_row(overrides = {}) + { + "ID" => "1", + "Title" => "A story of healing", + "Content" => "

Once upon a time.

", + "Status" => "publish", + "organization_name" => "A Greater Hope", + "story_workshop_name" => "Adult Windows Workshop", + "Tags" => "Adults", + "Categories" => "Domestic Violence", + "showhide_the_name" => "display_full_name" + }.merge(overrides) + end + + def import(rows, **opts) + described_class.new(csv_path: csv_file(rows), import_user: import_user, **opts).call + end + + it "creates a story idea for every row" do + result = import([ base_row, base_row("ID" => "2", "Title" => "Second", "organization_name" => "New Leaf") ]) + + expect(result.ideas_created).to eq(2) + expect(StoryIdea.count).to eq(2) + end + + it "creates a connected published story for published rows" do + import([ base_row ]) + + story = Story.sole + expect(story.published).to be(true) + expect(story.publicly_visible).to be(true) + expect(story.story_idea).to eq(StoryIdea.sole) + expect(story.title).to eq("A story of healing") + end + + it "does not create a story for draft rows" do + result = import([ base_row("Status" => "draft") ]) + + expect(result.ideas_created).to eq(1) + expect(result.stories_created).to eq(0) + expect(Story.count).to eq(0) + end + + it "maps showhide_the_name to author_credit_preference" do + import([ base_row("showhide_the_name" => "hide_full_name") ]) + + expect(StoryIdea.sole.author_credit_preference).to eq("anonymous") + end + + it "decodes HTML entities in the organization name and reuses the org" do + import([ + base_row("ID" => "1", "organization_name" => "Smith & Co"), + base_row("ID" => "2", "Title" => "Another", "organization_name" => "Smith & Co") + ]) + + expect(Organization.where("LOWER(name) = ?", "smith & co").count).to eq(1) + end + + it "derives windows type from audience tags" do + import([ + base_row("ID" => "1", "Title" => "Kids", "Tags" => "Children"), + base_row("ID" => "2", "Title" => "Grown", "Tags" => "Adults"), + base_row("ID" => "3", "Title" => "Both", "Tags" => "Families") + ]) + + expect(StoryIdea.find_by(title: "Kids").windows_type).to eq(children_wt) + expect(StoryIdea.find_by(title: "Grown").windows_type).to eq(adult_wt) + expect(StoryIdea.find_by(title: "Both").windows_type).to eq(combined_wt) + end + + it "tags a matching sector via the synonym map" do + sector = create(:sector, name: "Domestic Violence") + import([ base_row("Categories" => "Domestic Violence") ]) + + expect(StoryIdea.sole.sectors).to include(sector) + expect(Story.sole.sectors).to include(sector) + end + + it "warns when a category has no matching sector" do + result = import([ base_row("Categories" => "Self-Care & Personal Growth") ]) + + expect(result.warnings).to include(a_string_matching(/no Sector match/)) + expect(StoryIdea.sole.sectors).to be_empty + end + + it "warns about unmapped fields rather than dropping them" do + result = import([ base_row("state" => "California", "facilitator_name" => "Eydie") ]) + + expect(result.warnings).to include(a_string_matching(/unmapped state "California"/)) + expect(result.warnings).to include(a_string_matching(/unmapped facilitator "Eydie"/)) + end + + it "skips a published story whose title is already taken but still keeps the idea" do + create(:story, title: "A story of healing") + result = import([ base_row ]) + + expect(result.ideas_created).to eq(1) + expect(result.stories_created).to eq(0) + expect(result.warnings).to include(a_string_matching(/title already taken/)) + end + + it "is idempotent for story ideas across re-runs" do + rows = [ base_row("Status" => "draft") ] + import(rows) + result = import(rows) + + expect(result.ideas_created).to eq(0) + expect(result.skipped).to include(a_string_matching(/already exists/)) + expect(StoryIdea.count).to eq(1) + end + + it "skips rows with a blank title" do + result = import([ base_row("Title" => "") ]) + + expect(result.skipped).to include(a_string_matching(/blank title/)) + expect(StoryIdea.count).to eq(0) + end + + describe "dry run" do + it "writes nothing but reports what would be created" do + result = import([ base_row ], dry_run: true) + + expect(StoryIdea.count).to eq(0) + expect(Story.count).to eq(0) + expect(result.ideas_created).to eq(1) + expect(result.stories_created).to eq(1) + end + end +end