Skip to content

Commit

Permalink
Merge branch 'main' into i347-full-text-search
Browse files Browse the repository at this point in the history
  • Loading branch information
Kirk Wang committed Feb 26, 2025
2 parents e0c039c + 40cb118 commit 1b34603
Show file tree
Hide file tree
Showing 24 changed files with 931 additions and 9 deletions.
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -284,7 +284,7 @@ The Search Filters section shows your chosen search criteria.

- Click “Export Questions” and select “XML”

This will download an `.xml` file (e.g. `questions-2023-12-13_15_38_01_508.classic-question-canvas.qti.xml`). To upload that file into Canvass you will need to convert the downloaded XML file into a `.zip` file. The way to do this varies by operating system.
This will download an `.xml` file (e.g. `questions-2023-12-13_15_38_01_508.classic-question-canvas.qti.xml`). To upload that file into Canvas you will need to convert the downloaded XML file into a `.zip` file. The way to do this varies by operating system.

As of <span class="timestamp-wrapper"><span class="timestamp">&lt;2023-12-13 Wed&gt; </span></span> we export into a “classic question” format; the rationalization being two fold:

Expand All @@ -305,7 +305,7 @@ In the `Rails` console run the following:
User.create!(email: "<EMAIL>", password: "<PASSWORD>")
```

### Reseting Questions and Data
### Resetting Questions and Data

Over the course of testing and experimenting, you might want to reset the questions.

Expand Down
29 changes: 28 additions & 1 deletion app/controllers/search_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ def index
# conversations with the client, we're looking to only export classic (as you can migrate a
# classic question to new format). This filename is another "helpful clue" and introduces
# later considerations for what the file format might be.
filename = "questions-#{now.strftime('%Y-%m-%d_%H:%M:%S:%L')}.classic-question-canvas.qti.xml"
filename = "#{export_filename(now)}.classic-question-canvas.qti.xml"
@questions = Question.filter(**filter_values)

if any_question_has_images?
Expand All @@ -35,8 +35,35 @@ def index
end
end

# download bookmarked questions
def download
@questions = Question.where(id: Bookmark.select(:question_id))
case params[:format]
when 'md'
md_download
when 'txt'
text_download
else
redirect_to authenticated_root_path, alert: t('.alert')
end
end

private

def export_filename(now = Time.current)
"questions-#{now.strftime('%Y-%m-%d_%H:%M:%S:%L')}"
end

def text_download
content = @questions.map { |question| QuestionFormatter::PlainTextService.new(question).format_content }.join('')
send_data content, filename: "#{export_filename}.txt", type: 'text/plain'
end

def md_download
content = @questions.map { |question| QuestionFormatter::MarkdownService.new(question).format_content }.join('')
send_data content, filename: "#{export_filename}.md", type: 'text/plain'
end

def any_question_has_images?
@questions.any? { |question| question.images.any? }
end
Expand Down
2 changes: 1 addition & 1 deletion app/javascript/components/ui/Footer/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ const Footer = () => {
<Nav.Link href='/settings'>Settings</Nav.Link>
</Nav>
<Nav className=''>
© 2023 VIVA
© { new Date().getFullYear() } VIVA
</Nav>
</Container>
</Navbar>
Expand Down
26 changes: 22 additions & 4 deletions app/javascript/components/ui/Search/SearchBar.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ const SearchBar = (props) => {
{query && (
<Button
variant='outline-secondary'
className='d-flex align-items-center fs-6 justify-content-center ms-2'
className='d-flex align-items-center fs-6 justify-content-center border-light-4'
size='lg'
onClick={handleReset}
>
Expand Down Expand Up @@ -170,7 +170,9 @@ const SearchBar = (props) => {
onChange={(event) => handleFilterChange(event, `selected${key.charAt(0).toUpperCase() + key.slice(1)}`)}
checked={filterState[`selected${key.charAt(0).toUpperCase() + key.slice(1)}`].includes(item)}
/>
<Form.Check.Label className='ps-2'>{item}</Form.Check.Label>
<Form.Check.Label className='ps-2'>
{key === 'types' && item.startsWith('question_') ? item.substring(9) : item}
</Form.Check.Label>
</Form.Check>
))}
</DropdownButton>
Expand All @@ -194,12 +196,28 @@ const SearchBar = (props) => {
View Bookmarks
</a>
<a
href={'.xml?bookmarked=true'}
href={'/questions/download?format=txt'}
className={`btn btn-primary p-2 m-2 ${!hasBookmarks ? 'disabled' : ''}`}
role='button'
aria-disabled={!hasBookmarks}
>
Export Bookmarks
Export as Plain Text
</a>
<a
href={'/questions/download?format=md'}
className={`btn btn-primary p-2 m-2 ${!hasBookmarks ? 'disabled' : ''}`}
role='button'
aria-disabled={!hasBookmarks}
>
Export as Markdown
</a>
<a
href={`/.xml?${bookmarkedQuestionIds.map(id => `bookmarked_question_ids[]=${encodeURIComponent(id)}`).join('&')}`}
className={`btn btn-primary p-2 m-2 ${!hasBookmarks ? 'disabled' : ''}`}
role='button'
aria-disabled={!hasBookmarks}
>
Export as XML
</a>
<Button
variant='danger'
Expand Down
5 changes: 4 additions & 1 deletion app/models/question.rb
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,9 @@ class Question < ApplicationRecord
class_attribute :required_csv_headers, default: %w[IMPORT_ID TEXT TYPE].freeze
class_attribute :type_label, default: "Question", instance_writer: false
class_attribute :type_name, default: "Question", instance_writer: false
# model_exporter is the method name in the formatters used for text downloading
# it must be defined in the inheriting classes
class_attribute :model_exporter, default: nil, instance_writer: false

##
# @!attribute qti_max_value [r|w]
Expand Down Expand Up @@ -332,7 +335,7 @@ def self.filter_as_json(select: FILTER_DEFAULT_SELECT, methods: FILTER_DEFAULT_M
only << :data unless only.include?(:data)

# Ensure the `filter` method is called with eager loading for associations
questions = filter(select: only, search: search, **kwargs)
questions = filter(select: only, search:, **kwargs)

# Convert to JSON and manually add image URLs and alt texts if they are included in the methods
questions.map do |question|
Expand Down
1 change: 1 addition & 0 deletions app/models/question/bow_tie.rb
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
# => true
class Question::BowTie < Question
self.type_name = "Bow Tie"
self.model_exporter = 'bowtie_type'

# NOTE: We're not storing this in a JSONB data type, but instead favoring a text field. The need
# for the data to be used in the application, beyond export of data, is minimal.
Expand Down
1 change: 1 addition & 0 deletions app/models/question/categorization.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ class Question::Categorization < Question
include MatchingQuestionBehavior

self.type_name = "Categorization"
self.model_exporter = 'categorization_type'
self.export_as_xml = true
self.choice_cardinality_is_multiple = true

Expand Down
1 change: 1 addition & 0 deletions app/models/question/drag_and_drop.rb
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
# data: [{ answer: "Aardvark", correct: true }, { answer: "Blue", correct: false }, { answer: "Yellow", correct:false }, { answer: "Cat", correct: true }])
class Question::DragAndDrop < Question
self.type_name = "Drag and Drop"
self.model_exporter = 'traditional_type'

##
# Represents the mapping process of a CSV Row to the underlying {Question::DragAndDrop}.
Expand Down
1 change: 1 addition & 0 deletions app/models/question/essay.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ class Question::Essay < Question
include MarkdownQuestionBehavior

self.type_name = "Essay"
self.model_exporter = 'essay_type'
self.export_as_xml = true

class ImportCsvRow < MarkdownQuestionBehavior::ImportCsvRow
Expand Down
1 change: 1 addition & 0 deletions app/models/question/matching.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ class Question::Matching < Question
include MatchingQuestionBehavior

self.type_name = "Matching"
self.model_exporter = 'matching_type'
self.export_as_xml = true
self.choice_cardinality_is_multiple = false

Expand Down
1 change: 1 addition & 0 deletions app/models/question/scenario.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
class Question::Scenario < Question
self.type_label = "Scenario"
self.type_name = "Scenario"
self.model_exporter = 'scenario'
self.included_in_filterable_type = false

class ImportCsvRow < Question::ImportCsvRow
Expand Down
1 change: 1 addition & 0 deletions app/models/question/select_all_that_apply.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
# duplication than more complicated inheritance.
class Question::SelectAllThatApply < Question
self.type_name = "Select All That Apply"
self.model_exporter = 'traditional_type'

##
# Represents the mapping process of a CSV Row to the underlying {Question::SelectAllThatApply}.
Expand Down
1 change: 1 addition & 0 deletions app/models/question/stimulus_case_study.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
class Question::StimulusCaseStudy < Question
self.type_label = "Case Study"
self.type_name = "Stimulus Case Study"
self.model_exporter = 'stimulus_type'
self.has_parts = true

has_many :as_parent_question_aggregations,
Expand Down
1 change: 1 addition & 0 deletions app/models/question/traditional.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ class Question::Traditional < Question
#
# @see https://github.com/notch8/viva/issues/261
self.type_name = "Multiple Choice"
self.model_exporter = 'traditional_type'

##
# Represents the mapping process of a CSV Row to the underlying {Question::Traditional}.
Expand Down
1 change: 1 addition & 0 deletions app/models/question/upload.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ class Question::Upload < Question
include MarkdownQuestionBehavior

self.type_name = "Upload"
self.model_exporter = 'essay_type'
self.export_as_xml = true

class ImportCsvRow < MarkdownQuestionBehavior::ImportCsvRow
Expand Down
125 changes: 125 additions & 0 deletions app/services/question_formatter/base_service.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
# frozen_string_literal: true
require 'nokogiri'

module QuestionFormatter
class BaseService
##
# Service to handle formatting questions for downloads
class_attribute :output_format, default: nil
attr_reader :question, :subq

def initialize(question, subq = false)
@question = question
@subq = subq
end

def format_content
format_by_type + divider_line
end

# These methods are protected instead of private so they can be called by other instances
protected

def essay_type
format_question_header + format_essay_content
end

def traditional_type
format_question_header + format_answers(@question.data) { |answer, index| format_traditional_answer(answer, index) }
end

def matching_type
format_question_header + format_answers(@question.data) { |answer, index| format_matching_answer(answer, index) }
end

def categorization_type
format_question_header + format_categories(@question.data)
end

def bowtie_type
format_question_header + format_bowtie_sections
end

def stimulus_type
output = @question.child_questions.map { |sub_question| format_sub_question(sub_question) }
# remove extra line breaks
output[-1] = output[-1].chomp if output.any?
"#{format_question_header}#{output.join('')}"
end

def format_sub_question(sub_question)
case sub_question.type
when "Question::Scenario"
format_scenario(sub_question)
else
"#{self.class.new(sub_question, true).format_by_type}\n"
end
end

def format_by_type
method = @question.class.model_exporter
begin
send(method)
rescue
"Question type: #{question_type} requires a valid export format method"
end
end

private

def divider_line
raise NotImplementedError, "Subclasses must implement divider_line"
end

def format_scenario(question)
raise NotImplementedError, "Subclasses must implement format_scenario"
end

def format_question_header
raise NotImplementedError, "Subclasses must implement format_question_header"
end

def format_essay_content
plain_text = format_html(@question.data['html'])
"Text: #{plain_text}\n"
end

def format_answers(data)
data.map.with_index { |answer, index| yield(answer, index) }.join('')
end

def format_traditional_answer(answer, index)
"#{index + 1}) #{answer['correct'] ? 'Correct' : 'Incorrect'}: #{answer['answer']}\n"
end

def format_matching_answer(answer, index)
"#{index + 1}) #{answer['answer']}\n Correct Match: #{answer['correct'].first}\n"
end

def format_categories(data)
raise NotImplementedError, "Subclasses must implement format_categories"
end

def format_bowtie_sections
sections = ['center', 'left', 'right'].map do |section|
answers = @question.data[section]['answers'].map.with_index do |answer, index|
format_traditional_answer(answer, index)
end.join('')
"#{section.capitalize}\n#{answers}"
end
sections.join("\n")
end

def question_type
@question.class.type_name
end

def format_html(html)
rich_text = Nokogiri::HTML(html)
rich_text.css('a').each { |link| link.replace("#{link.text} (#{link['href']})") }
rich_text.css('p').each { |p| p.replace("#{p.text}\n") }
rich_text.css('li').each { |li| li.replace("- #{li.text}\n") }
rich_text.text.strip
end
end
end
41 changes: 41 additions & 0 deletions app/services/question_formatter/markdown_service.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# frozen_string_literal: true

##
# Service to handle formatting questions into markdown
module QuestionFormatter
class MarkdownService < BaseService
self.output_format = 'md'

private

def divider_line
"\n---\n\n"
end

def format_scenario(sub_question)
"**Scenario:** #{sub_question.text}\n\n"
end

def format_question_header
headers = case @subq
when true
"### Subquestion Type: #{question_type}\n**Subquestion:** #{@question.text}\n\n"
else
"## QUESTION TYPE: #{question_type}\n**QUESTION:** #{@question.text}\n\n"
end
headers
end

def format_essay_content
plain_text = format_html(@question.data['html'])
"**Text:** #{plain_text}\n"
end

def format_categories(data)
data.map do |category|
items = category['correct'].map.with_index { |item, index| "#{index + 1}) #{item}\n" }.join('')
"**Category:** #{category['answer']}\n#{items}"
end.join("\n")
end
end
end
Loading

0 comments on commit 1b34603

Please sign in to comment.