Skip to content

Commit 3b00560

Browse files
authored
Merge pull request #420 from openstax/raise-columns
RAISE columns
2 parents 66cc8f9 + 5421c49 commit 3b00560

File tree

10 files changed

+3049
-3527
lines changed

10 files changed

+3049
-3527
lines changed

.github/workflows/assets.yml

+1-1
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ on:
1212
jobs:
1313
assets:
1414
timeout-minutes: 30
15-
runs-on: ubuntu-20.04
15+
runs-on: ubuntu-22.04
1616

1717
steps:
1818
- uses: actions/checkout@v4

.github/workflows/migrations.yml

+1-1
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ on:
1414
jobs:
1515
migrations:
1616
timeout-minutes: 30
17-
runs-on: ubuntu-20.04
17+
runs-on: ubuntu-22.04
1818
services:
1919
postgres:
2020
image: postgres:11

.github/workflows/tests.yml

+1-1
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ on:
1414
jobs:
1515
tests:
1616
timeout-minutes: 30
17-
runs-on: ubuntu-20.04
17+
runs-on: ubuntu-22.04
1818
services:
1919
postgres:
2020
image: postgres:11

app/routines/exercises/import/assessments.rb

+138-73
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,5 @@
11
# Imports exercies from a spreadsheet for Assessments
2-
# The first row contains column headers. Required columns:
3-
# UUID (page UUID)
4-
# Pre or Post
5-
# Question Stem
6-
# Answer Choice A
7-
# Answer Choice B
8-
# Answer Choice C
9-
# Answer Choice D
10-
# Correct Answer (A, B, C or D)
11-
# Detailed Solution
2+
# The first row contains column headers. The only required column is Question Stem.
123
module Exercises
134
module Import
145
class Assessments
@@ -25,79 +16,170 @@ def exec(filename:, book_uuid:)
2516
author = User.find(AUTHOR_ID)
2617
copyright_holder = User.find(COPYRIGHT_HOLDER_ID) rescue author # So it works in the dev env with only 1 user
2718

19+
initialized = false
20+
21+
question_stem_index = nil
2822
uuid_index = nil
23+
section_index = nil
24+
25+
page_uuid_by_book_location = {}
26+
27+
nickname_index = nil
28+
2929
pre_or_post_index = nil
30-
question_stem_index = nil
30+
31+
teks_index = nil
32+
machine_teks_index = nil
33+
34+
raise_id_index = nil
35+
36+
background_index = nil
37+
multi_step_index = nil
38+
block_index = nil
39+
3140
answer_choice_indices = nil
3241
correct_answer_index = nil
42+
feedback_indices = nil
43+
3344
detailed_solution_index = nil
45+
46+
row_number = nil
47+
48+
multi_step = nil
49+
exercise = nil
3450
record_failures do |failures|
35-
ProcessSpreadsheet.call(filename: filename, headers: :downcase) do |headers, row, row_index|
36-
uuid_index ||= headers.index { |header| header == 'uuid' || header == 'page uuid' }
51+
save = ->(exercise, row_number) do
52+
return if exercise.nil? || row_number.nil?
3753

38-
section_index ||= headers.index { |header| header == 'section' }
39-
raise ArgumentError, 'Could not find "UUID" or "Section" columns' if uuid_index.nil? && section_index.nil?
54+
begin
55+
exercise.save!
56+
exercise.publication.publish.save!
4057

41-
unless section_index.nil?
42-
book = OpenStax::Content::Abl.new.approved_books.find { |book| book.uuid == book_uuid }
43-
page_uuid_by_book_location = {}
44-
book.all_pages.each { |page| page_uuid_by_book_location[page.book_location] = page.uuid }
45-
raise ArgumentError, "Could not find book with UUID #{book_uuid} in the ABL" if book.nil?
58+
Rails.logger.info { "Imported row ##{row_number} - New exercise ID: #{exercise.uid}" }
59+
rescue StandardError => error
60+
Rails.logger.error { "Failed to import row ##{row_number} - #{error.message}" }
61+
failures[row_number] = error.to_s
4662
end
63+
end
4764

48-
pre_or_post_index ||= headers.index { |header| header&.start_with?('pre') && header.end_with?('post') }
49-
raise ArgumentError, 'Could not find "Pre or Post" column' if pre_or_post_index.nil?
65+
ProcessSpreadsheet.call(filename: filename, headers: :downcase) do |headers, row, row_index|
66+
unless initialized
67+
question_stem_index ||= headers.index do |header|
68+
header&.start_with?('question') || header&.end_with?('stem')
69+
end
70+
raise ArgumentError, 'Could not find "Question Stem" column' if question_stem_index.nil?
5071

51-
question_stem_index ||= headers.index do |header|
52-
header&.start_with?('question') || header&.end_with?('stem')
53-
end
54-
raise ArgumentError, 'Could not find "Question Stem" column' if question_stem_index.nil?
72+
uuid_index ||= headers.index { |header| header == 'uuid' || header == 'page uuid' }
73+
section_index ||= headers.index { |header| header == 'section' }
74+
Rails.logger.warn { 'Could not find "UUID" or "Section" columns' } \
75+
if uuid_index.nil? && section_index.nil?
5576

56-
answer_choice_indices ||= headers.filter_map.with_index do |header, index|
57-
index if (header&.start_with?('answer') || header&.end_with?('choice')) && !header.include?('feedback')
58-
end
59-
raise ArgumentError, 'Could not find "Answer Choice" columns' if answer_choice_indices.empty?
77+
unless section_index.nil?
78+
book = OpenStax::Content::Abl.new.approved_books.find { |book| book.uuid == book_uuid }
79+
book.all_pages.each { |page| page_uuid_by_book_location[page.book_location] = page.uuid }
80+
raise ArgumentError, "Could not find book with UUID #{book_uuid} in the ABL" if book.nil?
81+
end
6082

61-
feedback_indices ||= headers.filter_map.with_index do |header, index|
62-
index if header&.include?('feedback')
63-
end
83+
nickname_index ||= headers.index { |header| header&.include?('nickname') }
6484

65-
correct_answer_index ||= headers.index { |header| header&.start_with?('correct') }
66-
raise ArgumentError, 'Could not find "Correct Answer" column' if correct_answer_index.nil?
85+
pre_or_post_index ||= headers.index { |header| header&.start_with?('pre') && header.end_with?('post') }
86+
Rails.logger.warn { 'Could not find "Pre or Post" column' } if pre_or_post_index.nil?
6787

68-
detailed_solution_index ||= headers.index { |header| header&.end_with?('solution') }
69-
raise ArgumentError, 'Could not find "Detailed Solution" column' if detailed_solution_index.nil?
88+
teks_index ||= headers.index { |header| header&.include?('teks') && !header.include?('machine') }
89+
machine_teks_index ||= headers.index { |header| header&.include?('machine') && header.include?('teks') }
7090

71-
row_number = row_index + 1
91+
raise_id_index ||= headers.index { |header| header&.include?('raise') }
7292

73-
page_uuid = if uuid_index.nil? || row[uuid_index].blank?
74-
page_uuid_by_book_location[row[section_index].split('.').map(&:to_i)] unless row[section_index].blank?
75-
else
76-
row[uuid_index]
77-
end
93+
background_index ||= headers.index { |header| header&.include?('background') }
94+
95+
multi_step_index ||= headers.index { |header| header&.include?('multi') && header.include?('step') }
96+
97+
block_index ||= headers.index { |header| header&.include?('block') }
7898

79-
if page_uuid.blank?
80-
Rails.logger.info { "Skipped row ##{row_number} with blank Section or Page UUID" }
81-
next
99+
answer_choice_indices ||= headers.filter_map.with_index do |header, index|
100+
index if (
101+
header&.start_with?('answer') || header&.start_with?('option') || header&.end_with?('choice')
102+
) && !header.include?('feedback')
103+
end
104+
Rails.logger.warn { 'Could not find "Answer Choice" columns' } if answer_choice_indices.empty?
105+
106+
correct_answer_index ||= headers.index { |header| header&.start_with?('correct') }
107+
Rails.logger.warn { 'Could not find "Correct Answer" column' } if correct_answer_index.nil?
108+
109+
feedback_indices ||= headers.filter_map.with_index do |header, index|
110+
index if header&.include?('feedback')
111+
end
112+
113+
detailed_solution_index ||= headers.index { |header| header&.end_with?('solution') }
114+
Rails.logger.warn { 'Could not find "Detailed Solution" column' } if detailed_solution_index.nil?
115+
116+
initialized = true
82117
end
83118

84-
exercise = Exercise.new
119+
row_number = row_index + 1
85120

86-
exercise.tags = [
87-
"assessment:#{row[pre_or_post_index].downcase == 'pre' ? 'preparedness' : 'practice'
88-
}:https://openstax.org/orn/book:page/#{book_uuid}:#{page_uuid}",
89-
"context-cnxmod:#{page_uuid}"
90-
]
121+
# Using row_index here because dealing with the previous row
122+
if multi_step_index.nil? || multi_step.nil? || row[multi_step_index] != multi_step
123+
save.call(exercise, row_index)
124+
125+
multi_step = row[multi_step_index] unless multi_step_index.nil?
126+
127+
exercise = Exercise.new
128+
129+
page_uuid = if uuid_index.nil? || row[uuid_index].blank?
130+
page_uuid_by_book_location[row[section_index].split('.').map(&:to_i)] \
131+
unless section_index.nil? || row[section_index].blank?
132+
else
133+
row[uuid_index]
134+
end
135+
if page_uuid.blank?
136+
Rails.logger.warn { "Row ##{row_number} has no associated page in the book" } \
137+
unless uuid_index.nil? && section_index.nil?
138+
else
139+
exercise.tags << "context-cnxmod:#{page_uuid}"
140+
exercise.tags << "assessment:#{row[pre_or_post_index].downcase == 'pre' ? 'preparedness' : 'practice'
141+
}:https://openstax.org/orn/book:page/#{book_uuid}:#{page_uuid}" \
142+
unless pre_or_post_index.nil? || row[pre_or_post_index].blank?
143+
end
144+
145+
unless teks_index.nil? || row[teks_index].blank?
146+
teks = row[teks_index].split(/,|;/).map(&:strip)
147+
teks.each { |tek| exercise.tags << "teks:#{tek}" }
148+
end
149+
unless machine_teks_index.nil? || row[machine_teks_index].blank?
150+
machine_teks = row[machine_teks_index].split(/,|;/).map(&:strip)
151+
machine_teks.each { |tek| exercise.tags << "machine-teks:#{tek}" }
152+
end
153+
exercise.tags << "raise-content-id:#{row[raise_id_index]}" \
154+
unless raise_id_index.nil? || row[raise_id_index].blank?
155+
156+
exercise.publication.authors << Author.new(user: author)
157+
exercise.publication.copyright_holders << CopyrightHolder.new(user: copyright_holder)
158+
159+
exercise.publication.publication_group.nickname = row[nickname_index] unless nickname_index.nil?
160+
161+
exercise.stimulus = parse(row[background_index], exercise) unless background_index.nil?
162+
else
163+
Rails.logger.info { "Imported row ##{row_index} - Multi-step exercise" }
164+
end
91165

92166
question = Question.new
167+
question.sort_position = row[block_index].to_i + 1 unless block_index.nil?
93168
exercise.questions << question
94169

95170
stem = Stem.new(content: parse(row[question_stem_index], exercise))
96171
stem.stylings << Styling.new(style: ::Style::MULTIPLE_CHOICE)
97172
question.stems << stem
98173

99-
exercise.publication.authors << Author.new(user: author)
100-
exercise.publication.copyright_holders << CopyrightHolder.new(user: copyright_holder)
174+
unless detailed_solution_index.nil? || row[detailed_solution_index].blank?
175+
solution = CollaboratorSolution.new(
176+
solution_type: SolutionType::DETAILED,
177+
content: parse(row[detailed_solution_index], exercise)
178+
)
179+
question.collaborator_solutions << solution
180+
end
181+
182+
next if correct_answer_index.nil? || row[correct_answer_index].blank?
101183

102184
correct_answer = row[correct_answer_index].downcase.strip.each_byte.first - 97
103185
answer_choice_indices.each_with_index do |row_index, answer_index|
@@ -113,26 +195,9 @@ def exec(filename:, book_uuid:)
113195
feedback: parse(feedback, exercise)
114196
)
115197
end
116-
117-
detailed_solution = row[detailed_solution_index]
118-
if detailed_solution.present?
119-
solution = CollaboratorSolution.new(
120-
solution_type: SolutionType::DETAILED,
121-
content: parse(detailed_solution, exercise)
122-
)
123-
question.collaborator_solutions << solution
124-
end
125-
126-
begin
127-
exercise.save!
128-
exercise.publication.publish.save!
129-
130-
Rails.logger.info { "Imported row ##{row_number} - New exercise ID: #{exercise.uid}" }
131-
rescue StandardError => error
132-
Rails.logger.error { "Failed to import row ##{row_number} - #{error.message}" }
133-
failures[row_number] = error.to_s
134-
end
135198
end
199+
200+
save.call(exercise, row_number)
136201
end
137202
end
138203
end

app/routines/process_spreadsheet.rb

+2-2
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,8 @@ def exec(filename:, offset: 1, pad_cells: true, headers: false, &block)
1818

1919
args = []
2020
pad_to_size = 0 if pad_cells
21-
klass.new(filename).public_send(method).each_with_index do |row, row_index|
22-
normalized_row = row.map { |cell| cell.value&.to_s&.strip }
21+
klass.new(filename).public_send(method, pad_cells: pad_cells).each_with_index do |row, row_index|
22+
normalized_row = row.map { |cell| cell&.value&.to_s&.strip }
2323

2424
if headers && row_index == 0
2525
header_row = normalized_row

lib/openstax_kramdown.rb

+64
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
require 'addressable/uri'
2+
3+
class Kramdown::Parser::Openstax < Kramdown::Parser::Html
4+
ESCAPED_PARENS_MATH_RE = /(.*?)\\\((.*?)\\\)(.*)/
5+
6+
def secrets
7+
@secrets ||= Rails.application.secrets
8+
end
9+
10+
def s3_secrets
11+
@s3_secrets ||= secrets.aws[:s3]
12+
end
13+
14+
def region
15+
@region ||= s3_secrets[:region]
16+
end
17+
18+
def s3_client
19+
@s3_client ||= Aws::S3::Client.new(
20+
region: region,
21+
credentials: Aws::Credentials.new(s3_secrets[:access_key_id], s3_secrets[:secret_access_key])
22+
)
23+
end
24+
25+
def add_text(text, tree = @tree, type = @text_type)
26+
matches = ESCAPED_PARENS_MATH_RE.match(text)
27+
return super(text, tree, type) if matches.nil?
28+
29+
super(matches[1], tree, type)
30+
31+
math = matches[2].strip
32+
last = tree.children.last
33+
location = (last && last.options[:location] || tree.options[:location])
34+
math_el = Kramdown::Element.new(:html_element, 'span', { 'data-math' => math }, location: location)
35+
tree.children << math_el
36+
super(math, math_el, :text)
37+
38+
add_text(matches[3], tree, type)
39+
end
40+
41+
def handle_html_start_tag(line, &block)
42+
super(line, &block).tap do
43+
next if Rails.env.development? || !@options[:attachable]
44+
last_el = @tree.children.last
45+
next unless last_el.type == :html_element && last_el.value == 'img' && last_el.attr['src']
46+
47+
uri = URI.parse(last_el.attr['src'])
48+
contents = Net::HTTP.get(uri)
49+
50+
bucket_name = s3_secrets[:uploads_bucket_name]
51+
key = "#{secrets.environment_name}/#{Digest::SHA2.new.update(contents).to_s}#{File.extname(uri.path)}"
52+
53+
s3_client.put_object(
54+
body: StringIO.new(contents),
55+
bucket: bucket_name,
56+
key: key
57+
)
58+
59+
last_el.attr['src'] = "https://s3.#{region}.amazonaws.com/#{bucket_name}/#{key}"
60+
61+
@options[:attachable].attachments << Attachment.new(asset: last_el.attr['src'])
62+
end
63+
end
64+
end

lib/row_parser.rb

+5-5
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,17 @@
1+
require 'openstax_kramdown'
2+
13
# Common methods for reading spreadsheets
24
module RowParser
35
# Parses the text using Markdown
46
# Attachments are associated with the given Exercise object
57
def parse(text, exercise)
68
return nil if text.blank?
79

8-
text = text.to_s
9-
10-
kd = Kramdown::Document.new(text.to_s.strip, math_engine: :openstax, attachable: exercise)
11-
# If only one <p> tag, remove it and just return the nodes below
10+
kd = Kramdown::Document.new(text.to_s.strip, input: 'Openstax', attachable: exercise)
11+
# If only one <p> tag after Kramdown parsing, remove it and just return the nodes below
1212
kd.root.children = kd.root.children.first.children \
1313
if kd.root.children.length == 1 && kd.root.children.first.type == :p
14-
kd.to_html
14+
kd.to_html.strip
1515
end
1616

1717
def record_failures

0 commit comments

Comments
 (0)