Skip to content
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

Create the import_chapter method #58

Merged
merged 17 commits into from
Oct 28, 2023
Merged
Show file tree
Hide file tree
Changes from 16 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
5 changes: 4 additions & 1 deletion Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,10 @@ group :development, :test do
gem 'factory_bot_rails'

gem 'dotenv-rails'

# CLI Stuff
gem 'diffy'
gem 'tty-prompt'
end

group :development do
Expand All @@ -79,6 +83,5 @@ end

gem 'contentful'
gem 'rack-cors'
gem 'tty-prompt'

gem 'dockerfile-rails', '>= 1.4', :group => :development
2 changes: 2 additions & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ GEM
irb (>= 1.5.0)
reline (>= 0.3.1)
diff-lcs (1.5.0)
diffy (3.4.2)
dockerfile-rails (1.4.1)
rails
domain_name (0.5.20190701)
Expand Down Expand Up @@ -316,6 +317,7 @@ DEPENDENCIES
capybara
contentful
debug
diffy
dockerfile-rails (>= 1.4)
dotenv-rails
factory_bot_rails
Expand Down
29 changes: 29 additions & 0 deletions app/models/book.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
# frozen_string_literal: true

require 'csv'
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I didn't think I needed this, but apparently I do 🤷🏾

class Book < ApplicationRecord
has_many :chapters, :dependent => :destroy

Expand All @@ -22,4 +23,32 @@ def last_tuk
def number_of_chapters_released
return self.chapters.released.count
end

##
# Create (or update) the a specific chapter via CSV
# HOW IT WORKS:
# 1. Add your CSV file to `lib/imports/#{book.sequence}/#{chapter_number}.csv`
# 2. Pass in a `chapter_number: Integer` e.g. `3`
# 3. Call `@book.import_chapter(3)`.
# EXAMPLE:
# @book = Book.find_by(:sequence => 1)
# @book.import_chapter(3)
# This will search for a CSV file at `lib/imports/1/3.csv` (or raise error)
# The CSV file should have the following columns:
# - Chapter_Number: Integer
# - Chapter_Name: String
# - Chhand_Type: String
# - Tuk: String
# - Pauri_Number: Integer
# - Tuk_Number: Integer
# - Pauri_Translation_EN: String | NULL
# - Tuk_Translation_EN: String | NULL
# - Footnotes: String | NULL
# - Extended_Ref: String | NULL
# - Assigned_Singh: String | NULL
# - Extended_Meaning: String | NULL
##
def import_chapter(chapter_number)
ChapterImporterService.new(self, chapter_number).call
end
end
15 changes: 15 additions & 0 deletions app/models/chapter.rb
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,19 @@ class Chapter < ApplicationRecord
else
scope :released, -> { all }
end

def csv_rows
file_path = "lib/imports/#{self.book.sequence}/#{self.number}.csv"

unless File.exist?(file_path)
puts "CSV file #{file_path} not found. " + Pastel.new.red.on_bright_white.bold("Are you sure you added it to #{file_path}?")
raise "CSV file #{file_path} not found. "
end

rows = []
CSV.foreach(file_path, :headers => true) do |row|
rows << row
end
return rows
end
end
132 changes: 132 additions & 0 deletions app/services/chapter_importer_service.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
# frozen_string_literal: true

class ChapterImporterService
attr_reader :book, :chapter_number, :pastel, :prompt, :chapter

def initialize(book, chapter_number)
@book = book
@chapter_number = chapter_number
@pastel = Pastel.new
@prompt = TTY::Prompt.new
end

def call
ensure_chapter_exists!
ActiveRecord::Base.transaction do
each_csv_row do |row|
process_row(row)
end
end
end

private

def each_csv_row(&)
blob = chapter.csv_rows
blob.each(&)
end

def ensure_chapter_exists!
@chapter = book.chapters.find_by(:number => chapter_number)
raise "Chapter not found: #{chapter_number}" unless @chapter
end

def process_row(row)
validate_and_update_chapter_title(row)
process_pauri(row)
process_tuk(row)
create_tuk_translation(row)
create_pauri_translation(row)
end

def validate_and_update_chapter_title(row)
chapter_name = row['Chapter_Name'].try(:strip)
return if chapter.title == chapter_name

message = "The name in Book #{book.sequence}, Chapter #{chapter_number} is " +
pastel.bold('presently') +

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: You could use string interpolation here instead of +

" '#{chapter.title}'. The CSV says '#{chapter_name}'."
prompt.say(message, :color => :yellow)
answer = prompt.yes?("Do you want to continue and update this title to '#{chapter_name}'?")
raise 'Aborted by user' unless answer

chapter.update(:title => chapter_name)
Rails.logger.debug pastel.green("✓ Chapter #{chapter_number}'s title updated to '#{chapter_name}'")
end

def process_pauri(row)
pauri_number = row['Pauri_Number'].to_i
@pauri = chapter.pauris.find_by(:number => pauri_number)

raise "Pauri not found: #{pauri_number}" if @pauri.nil?
end

def process_tuk(row)
tuk = row['Tuk'].try(:strip)
tuk_number = row['Tuk_Number'].to_i
@tuk = @pauri.tuks.find_by(:sequence => tuk_number)

raise "Tuk #{tuk_number} not found: #{tuk}" if @tuk.nil?

return if @tuk.original_content == tuk

diff = Diffy::Diff.new(@tuk.original_content, tuk, :include_diff_info => true).to_s(:color)
Rails.logger.debug diff

choices = [
"Keep the NEW one from the CSV (update the original) :: #{tuk}",
pastel.red("Keep the original :: #{@tuk.original_content}")
]

selected_choice = prompt.select('Choose an option:', choices)
case selected_choice
when choices[0]
@tuk.update(:original_content => tuk)
Rails.logger.debug pastel.green("✓ `Tuk` `original_content` updated to #{tuk} - For #{@pauri.number}.#{tuk_number}")
when choices[1]
Rails.logger.debug pastel.red("x Keeping the original `Tuk` `original_content` - For #{@pauri.number}.#{tuk_number}")
end
end

def create_tuk_translation(row)
tuk_translation_en = row['Tuk_Translation_EN'].try(:strip) || row['Translation_EN'].try(:strip)
return if tuk_translation_en.blank?

# If there's a Pauri translation and a Tuk translation, ask what to do
if @pauri.translation.present?
choices = [
'Continue with the `TukTranslation` and KEEP the `PauriTranslation`, too!',
'Destroy the existing `PauriTranslation`, and continue with only the `TukTranslation`',
pastel.red('Abort')
]
selected_choice = prompt.select('Choose an option:', choices)

case selected_choice
when choices[0]
upsert_tuk_translation(tuk_translation_en, row['Assigned_Singh'].try(:strip))
when choices[1]
upsert_tuk_translation(tuk_translation_en, row['Assigned_Singh'].try(:strip))
@pauri.translation.destroy
when choices[2]
raise 'Aborted by user'
end
else
upsert_tuk_translation(tuk_translation_en, row['Assigned_Singh'].try(:strip))
end
end

def upsert_tuk_translation(translation, translator)
@tuk_translation = @tuk.translation || TukTranslation.new(:tuk_id => @tuk.id)
@tuk_translation.update(:en_translation => translation, :en_translator => translator)
Rails.logger.debug pastel.green("✓ `TukTranslation` created or updated for Tuk #{translation} - Pauri # #{@pauri.number}")
end

def create_pauri_translation(row)
pauri_translation_en = row['Pauri_Translation_EN'].try(:strip)
return if pauri_translation_en.blank?

pauri_translation = @pauri.translation || PauriTranslation.new(:pauri_id => @pauri.id)
pauri_translation.update(:en_translation => pauri_translation_en, :en_translator => row['Assigned_Singh'].try(:strip))
Rails.logger.debug pastel.green("✓ `PauriTranslation` created or updated for Pauri # #{@pauri.number}")
end
end
123 changes: 123 additions & 0 deletions spec/models/book_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
# rubocop:disable RSpec/NestedGroups, RSpec/MultipleExpectations
# frozen_string_literal: true

require 'rails_helper'
require 'csv'

RSpec.describe Book do
let(:book) { create(:book) }

describe '#import_chapter' do
context 'when the `chapter.number` is invalid' do
it 'raises an error if the `chapter_number` does not exist in `book`' do
expect { book.import_chapter(99) }.to raise_error(RuntimeError, /Chapter not found: 99/)
end

it 'raises an error if the CSV does not exist' do
# Create the `Chapter` row only! Not the CSV.
create(:chapter, :book => book, :number => 100)
expect { book.import_chapter(100) }.to raise_error(RuntimeError, %r{CSV file lib/imports/1/100\.csv not found})
end
end

context 'when the `chapter_number` is valid' do
csv_content = <<~CSV
Chapter_Number,Chapter_Name,Chhand_Type ,Tuk,Pauri_Number,Tuk_Number,Pauri_Translation_EN,Translation_EN ,Footnotes,Custom_Footnotes,Extended_Ref ,Assigned_Singh,Status,Extended_Meaning
CSV

csv_rows = CSV.parse(csv_content, :headers => true)
let(:chapter) { create(:chapter, :book => book) }

it 'does not raise an error' do
file_path = "lib/imports/#{book.sequence}/#{chapter.number}.csv"

allow(File).to receive(:exist?).with(file_path).and_return(true)
allow(CSV).to receive(:foreach).with(file_path, :headers => true).and_return(csv_rows)

expect { book.import_chapter(chapter.number) }.not_to raise_error
end
end

context 'when given valid input and CSV' do
let(:chapter_number) { 99 }
let(:chapter_title) { 'ਇਸ਼੍ਟ ਦੇਵ-ਸ਼੍ਰੀ ਅਕਾਲ ਪੁਰਖ-ਮੰਗਲ' }
let(:csv_content) do
<<~CSV
Chapter_Number,Chapter_Name,Chhand_Type ,Tuk,Pauri_Number,Tuk_Number,Pauri_Translation_EN,Translation_EN ,Footnotes,Custom_Footnotes,Extended_Ref ,Assigned_Singh,Status,Extended_Meaning
99,ਇਸ਼੍ਟ ਦੇਵ-ਸ਼੍ਰੀ ਅਕਾਲ ਪੁਰਖ-ਮੰਗਲ,ਦੋਹਰਾ,"ਤੀਨੋ ਕਾਲ ਅਲਿਪਤ ਰਹਿ, ਖੋਜੈਂ ਜਾਂਹਿ ਪ੍ਰਬੀਨ",1,1,,,,,,,,
99,ਇਸ਼੍ਟ ਦੇਵ-ਸ਼੍ਰੀ ਅਕਾਲ ਪੁਰਖ-ਮੰਗਲ,ਦੋਹਰਾ,"ਬੀਨਤਿ ਸਚਿਦਾਨੰਦ ਤ੍ਰੈ, ਜਾਨਹਿਂ ਮਰਮ ਰਤੀ ਨ",1,2,,,,,,,,
CSV
end

let(:chapter) { create(:chapter, :number => chapter_number, :book => book, :title => chapter_title) }
let(:chhand_type) { create(:chhand_type, :name => 'ਦੋਹਰਾ') }
let(:chhand) { create(:chhand, :chhand_type => chhand_type, :chapter => chapter) }
let(:pauri) { create(:pauri, :chapter => chapter, :chhand => chhand, :number => 1) }
let(:prompt) { instance_double(TTY::Prompt) }

before do
# Initialize mocks for TTY::Prompt
allow(TTY::Prompt).to receive(:new).and_return(prompt)
allow(prompt).to receive(:say)

# Associations for the chapter - This one reflects out mock `csv_content`
create(:tuk, :pauri => pauri, :chapter => chapter, :original_content => 'ਤੀਨੋ ਕਾਲ ਅਲਿਪਤ ਰਹਿ, ਖੋਜੈਂ ਜਾਂਹਿ ਪ੍ਰਬੀਨ', :sequence => 1)
create(:tuk, :pauri => pauri, :chapter => chapter, :original_content => 'ਬੀਨਤਿ ਸਚਿਦਾਨੰਦ ਤ੍ਰੈ, ਜਾਨਹਿਂ ਮਰਮ ਰਤੀ ਨ', :sequence => 2)

# Mocking the CSV data
allow(book.chapters).to receive(:find_by).with(:number => chapter_number).and_return(chapter)
allow(chapter).to receive(:csv_rows).and_return(CSV.parse(csv_content, :headers => true))
end

context 'when prompted to update `chapter.title`' do
it 'does not prompt user if the `chapter.title` is unchanged' do
allow(prompt).to receive(:yes?)
book.import_chapter(chapter_number)
expect(prompt).not_to have_received(:yes?)

expect(chapter.reload.title).to eq(chapter_title)
end

it 'updates the `chapter.title` if user confirms' do
# Change the `chapter.title` so it is different than the one in CSV
chapter.update(:title => 'Different title')

allow(prompt).to receive(:yes?).and_return(true)
book.import_chapter(chapter_number)

expect(prompt).to have_received(:yes?).with("Do you want to continue and update this title to '#{chapter_title}'?")
expect(chapter.reload.title).to eq(chapter_title)
end

it 'aborts and does not update the chapter title if user declines' do
# Change the `chapter.title` so it is different than the one in CSV
chapter.update(:title => 'Something Else - Suraj Suraj Suraj')
allow(prompt).to receive(:yes?).and_return(false)
expect { book.import_chapter(chapter_number) }.to raise_error(RuntimeError, 'Aborted by user')
end
end

context 'when `chapter` associations do not exist' do
it 'raises an error when `pauri` is nil' do
pauri.destroy
expect { book.import_chapter(chapter_number) }.to raise_error(StandardError, /Pauri not found/)
end

it 'raises an error when `tuks` are nil' do
Tuk.destroy_all
expect { book.import_chapter(chapter_number) }.to raise_error(StandardError, /Tuk 1 not found/)
end

it 'raises an error when 2nd `tuk` is missing' do
chapter.tuks.second.destroy
expect { book.import_chapter(chapter_number) }.to raise_error(StandardError, /Tuk 2 not found/)
end
end

# TODO: Write tests for Translations, Footnotes, etc.
# TODO: and write tests for the TTY stuff
end
end
end

# rubocop:enable RSpec/NestedGroups, RSpec/MultipleExpectations
2 changes: 1 addition & 1 deletion spec/models/chapter_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@

it 'cannot save when `book_id` does not belong to a real book' do
expect do
Chapter.create!({ :book_id => 1, :number => 2, :title => 'Mangal' })
Chapter.create!({ :book_id => 99, :number => 2, :title => 'Mangal' })
end.to raise_error(ActiveRecord::RecordInvalid)
end
end
Expand Down
Loading