alembic 0.7.2 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ef24a6bad349612e40a70a030fee5e1f7d5eaee540c29fb20645f489fde6de05
4
- data.tar.gz: 633acbe75014d6fbb3d5735e9141d08fd90b54e32c89fa20f2abc09583d03569
3
+ metadata.gz: b9fe70ef3945d5a70f24110f819a17db43ee9f54ebf70e81631e76e96fe4e7a2
4
+ data.tar.gz: eb5c70109254d9e121b2fbe5b8bbf6ce7988e53deaf322a9a904ee556c6ede6a
5
5
  SHA512:
6
- metadata.gz: a92ef4a24bb3a47ae63d14b999043b5f48d393960e18e6a338a0e0074a11e311508f6637e0393250ae928e2013ce0fa5fda97e40ac5f732e3afa7b7323eec4bd
7
- data.tar.gz: 20b1066d5f7977222a01d415db83fe5de05f0d28c94c126baab9592b431b514bd5a46cefcfc6efbaef9443a44948063f546ed4666f32a68ca444e7a3d77d6e41
6
+ metadata.gz: 98520320480c38a5a72a8d11b11f0510b70352c8a6a9765c732842dae8075a6f843c419f29fe9c3308a5b9b8230fe8a20840274d4e5b866aa741ea414b2cbab0
7
+ data.tar.gz: 131062d6d22160f9ab6b3688d33f43120b783410d9836c2c03d09d6617b8efe47c7656896eca9bb0537151bf801cf1971564b7543afc279994071b40965636d3
@@ -9,6 +9,7 @@ module Alembic
9
9
  helper ResultBlocksHelper
10
10
  helper ContentBlocksHelper
11
11
  helper KsBlocks::LayoutHelper
12
+ after_action :keep_ended_inner_run, only: :step
12
13
  helper_method :flow_summary, :first_step_path, :summary_values, :intro_values, :start_over_path, :finish_link, :visited_flow_address
13
14
  end
14
15
 
@@ -42,6 +43,16 @@ module Alembic
42
43
  super.tap { |run| Flow::Summaries.new(flow).pin(run) }
43
44
  end
44
45
 
46
+ def admitted_run
47
+ super.tap { |found| Flow::Summaries.new(found.flow).pin_inner(found) }
48
+ end
49
+
50
+ def keep_ended_inner_run
51
+ return unless run&.parent_run && run.ended_at_end?
52
+
53
+ Flow::Summaries.new(run.flow).keep(run, run.walked(run.recorded).transform_keys(&:to_s))
54
+ end
55
+
45
56
  def finished(answers, finished_run)
46
57
  @outputs = outputs_of(answers.transform_keys(&:to_s))
47
58
  Flow::Summaries.new(flow).pin(finished_run) if finished_run && run.nil?
@@ -15,6 +15,13 @@ module Alembic
15
15
  "steps_text" => Array(entry["steps"]).map { |step| "#{step['title']}\n#{step['code']}" }.join("\n---\n") }
16
16
  end
17
17
 
18
+ def alembic_question_upload_path(flow)
19
+ registration = defined?(Ixport) && Ixport.registrations.values.find { |found| found.importer == "Alembic::QuestionImporter" }
20
+ return unless registration && respond_to?(:ixport)
21
+
22
+ ixport.new_import_path(key: registration.key, target: flow.to_global_id)
23
+ end
24
+
18
25
  def alembic_output_lines(value)
19
26
  case value
20
27
  when Hash then value.key?("name") ? [ value["name"] ] : value.map { |name, share| "#{name}: #{share}" }
@@ -1,6 +1,8 @@
1
1
  module Alembic
2
2
  module Flow
3
3
  class Summaries
4
+ SCORES = %w[weighted_sum inner_total].freeze
5
+
4
6
  def initialize(flow)
5
7
  @flow = flow
6
8
  end
@@ -77,6 +79,10 @@ module Alembic
77
79
  RunSummary.create!(run: run, summary_version: current_version) if current_version
78
80
  end
79
81
 
82
+ def pin_inner(run)
83
+ pin(run) if run.parent_run && pinned_to(run).nil?
84
+ end
85
+
80
86
  def pinned_to(run)
81
87
  RunSummary.find_by(run: run)&.summary_version
82
88
  end
@@ -96,15 +102,15 @@ module Alembic
96
102
  pinned = pinned_to(run)&.summary
97
103
  return [] if pinned.blank?
98
104
 
99
- Summary::Report.new(pinned).results(Summary::Run.new(state: state, steps: run.pinned_steps))
105
+ Summary::Report.new(pinned).results(Summary::Run.new(state: state, steps: run.pinned_steps, run: run))
100
106
  end
101
107
 
102
- private
103
-
104
108
  def score_of(run, state)
105
- of_run(run, state).find { |result| result.type == "weighted_sum" }&.value
109
+ of_run(run, state).find { |result| SCORES.include?(result.type) }&.value
106
110
  end
107
111
 
112
+ private
113
+
108
114
  def seconds_taken_by(run)
109
115
  return unless run.flow.each_step?
110
116
 
@@ -0,0 +1,87 @@
1
+ module Alembic
2
+ module Flow
3
+ class UploadedQuestion
4
+ include ActiveModel::Model
5
+ include ActiveModel::Attributes
6
+
7
+ attr_accessor :flow
8
+
9
+ attribute :type, :string
10
+ attribute :question, :string
11
+ attribute :category, :string
12
+ attribute :right_answer, :string
13
+ ANSWER_FIELDS = (1..6).map { |number| :"answer_#{number}" }
14
+ ANSWER_FIELDS.each { |field| attribute field, :string }
15
+
16
+ KINDS = %w[truefalse multiplechoice].freeze
17
+
18
+ validates :question, presence: true
19
+ validate :kind_is_offered
20
+ validate :choices_are_offered, if: -> { kind == "multiplechoice" }
21
+ validate :true_or_false_is_right, if: -> { kind == "truefalse" }
22
+
23
+ def save!
24
+ before = document.to_h
25
+ flow.edit_history.record(appended, :added, [ step["id"] ], before)
26
+ end
27
+
28
+ private
29
+
30
+ def choices_are_offered
31
+ errors.add(:answers, "must be at least two for a multiple choice question") if filled_answers.size < 2
32
+ errors.add(:right_answer, "must be the number of one of its answers") if answers.none? { |answer| answer["weight"] == 1 }
33
+ end
34
+
35
+ def true_or_false_is_right
36
+ errors.add(:right_answer, "must be true or false") if answers.none? { |answer| answer["weight"] == 1 }
37
+ end
38
+
39
+ def kind_is_offered
40
+ errors.add(:type, "must be true/false or multiple choice") unless KINDS.include?(kind)
41
+ end
42
+
43
+ def document
44
+ EasyFlow::Document.new(flow.document || flow.definition || {})
45
+ end
46
+
47
+ def appended
48
+ leaving = document.edges_from(last_question).first
49
+ return document.insert(step, on: [ last_question, leaving.to ]) if leaving
50
+
51
+ document.add(step).connect(from: last_question, to: step["id"])
52
+ end
53
+
54
+ def last_question
55
+ reached = document.reachable
56
+ reached.reverse.find { |id| document.node(id)&.type == "question" } || reached.last
57
+ end
58
+
59
+ def step
60
+ @step ||= { "id" => next_id, "type" => "question", "question" => question, "category" => category.presence, "answers" => answers }.compact
61
+ end
62
+
63
+ def answers
64
+ return true_false_answers if kind == "truefalse"
65
+
66
+ filled_answers.map { |number, text| { "value" => text, "weight" => number.to_s == right_answer.to_s.strip ? 1 : 0 } }
67
+ end
68
+
69
+ def true_false_answers
70
+ %w[True False].map { |text| { "value" => text, "weight" => text.casecmp?(right_answer.to_s.strip) ? 1 : 0 } }
71
+ end
72
+
73
+ def kind
74
+ type.to_s.downcase.gsub(/[^a-z]/, "")
75
+ end
76
+
77
+ def filled_answers
78
+ ANSWER_FIELDS.each_with_index.filter_map { |field, index| [ index + 1, public_send(field).strip ] if public_send(field).present? }
79
+ end
80
+
81
+ def next_id
82
+ taken = document.nodes.map(&:id)
83
+ (1..).lazy.map { |number| "question_#{number}" }.find { |id| !taken.include?(id) }
84
+ end
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,17 @@
1
+ module Alembic
2
+ module Flow
3
+ class UploadedQuestions
4
+ def initialize(flow)
5
+ @flow = flow
6
+ end
7
+
8
+ def new(values)
9
+ UploadedQuestion.new(values.merge(flow: @flow))
10
+ end
11
+
12
+ def where(*)
13
+ []
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,26 @@
1
+ module Alembic
2
+ module Outputs
3
+ module InnerTotal
4
+ def self.output_type
5
+ Summary::OutputType.define(:inner_total) do
6
+ label "Total score"
7
+ compute { |_config, run, _so_far| InnerTotal.total(run.run) }
8
+ end
9
+ end
10
+
11
+ def self.register(registry = Summary.registry)
12
+ registry.register(output_type)
13
+ end
14
+
15
+ def self.total(parent)
16
+ return 0 if parent.nil?
17
+
18
+ parent.inner_runs.select(&:ended_at_end?).sum { |inner| score_of(inner).to_i }
19
+ end
20
+
21
+ def self.score_of(inner)
22
+ Flow::Summaries.new(inner.flow).score_of(inner, inner.walked(inner.recorded).transform_keys(&:to_s))
23
+ end
24
+ end
25
+ end
26
+ end
@@ -24,6 +24,9 @@ module Alembic
24
24
  end
25
25
 
26
26
  def self.most(step)
27
+ return 1 if step["type"] == "multiple_select"
28
+ return WeightedSum.weight_of(step, Array(step["accepted_answers"]).first&.dig("value")) if step["type"] == "fill_in_the_blank"
29
+
27
30
  EasyFlow::Steps::Question.answers_of(step).filter_map { |option| option["weight"].to_i if option.is_a?(Hash) }.max.to_i
28
31
  end
29
32
  end
@@ -17,10 +17,33 @@ module Alembic
17
17
  end
18
18
 
19
19
  def self.weight_of(step, value)
20
+ return picked_weight(step, value) if step["type"] == "multiple_select"
21
+ return typed_weight(step, value) if step["type"] == "fill_in_the_blank"
22
+
23
+ answer_weight(step, value)
24
+ end
25
+
26
+ def self.answer_weight(step, value)
20
27
  chosen = EasyFlow::Steps::Question.answers_of(step).find { |option| option.is_a?(Hash) && option["value"] == value }
21
28
 
22
29
  chosen&.fetch("weight", nil).to_i
23
30
  end
31
+
32
+ def self.picked_weight(step, values)
33
+ Array(values).sort == right_answers(step).sort ? 1 : 0
34
+ end
35
+
36
+ def self.typed_weight(step, text)
37
+ Array(step["accepted_answers"]).any? { |accepted| comparable(accepted["value"]) == comparable(text) } ? 1 : 0
38
+ end
39
+
40
+ def self.comparable(text)
41
+ text.to_s.strip.downcase
42
+ end
43
+
44
+ def self.right_answers(step)
45
+ EasyFlow::Steps::Question.answers_of(step).filter_map { |option| option["value"] if option["weight"].to_i.positive? }
46
+ end
24
47
  end
25
48
  end
26
49
  end
@@ -1,7 +1,7 @@
1
1
  module Alembic
2
2
  module Summary
3
- Run = Data.define(:state, :steps) do
4
- def initialize(state:, steps: {})
3
+ Run = Data.define(:state, :steps, :run) do
4
+ def initialize(state:, steps: {}, run: nil)
5
5
  super
6
6
  end
7
7
 
@@ -58,4 +58,10 @@
58
58
  <%= hidden_field_tag "#{field}[type]", "outcome", id: nil %>
59
59
  <%= ui_input(name: "#{field}[id]", placeholder: "The new outcome's id") %>
60
60
  </div>
61
+ <% field = "flow[outputs][#{outputs.size + 1}]" %>
62
+ <div data-new-inner-total class="mt-6">
63
+ <label class="ks-label mb-1">Add a total of every inner run's score</label>
64
+ <%= hidden_field_tag "#{field}[type]", "inner_total", id: nil %>
65
+ <%= ui_input(name: "#{field}[id]", placeholder: "The new total's id") %>
66
+ </div>
61
67
  <% end %>
@@ -5,6 +5,12 @@
5
5
 
6
6
  <%= ui_form_page(title: "Edit details", back_url: easy_flow.manage_flow_path(@flow)) %>
7
7
 
8
+ <% if (upload_path = alembic_question_upload_path(@flow)) %>
9
+ <%= ui_section(title: "Questions from a file") do %>
10
+ <%= link_to "Upload questions", upload_path, class: "text-sm text-(color:--ks-color-link) hover:text-(color:--ks-color-link-hover) dark:text-(color:--ks-color-link-dark) dark:hover:text-(color:--ks-color-link-hover-dark)" %>
11
+ <% end %>
12
+ <% end %>
13
+
8
14
  <%= ui_form(action: easy_flow.manage_flow_path(@flow), method: :patch) do %>
9
15
  <%= ui_section(title: "How this is introduced") do %>
10
16
  <label class="ks-label mb-1">Title</label>
@@ -29,9 +29,14 @@ module Alembic
29
29
  Alembic::Outputs::Tally.register
30
30
  Alembic::Outputs::Band.register
31
31
  Alembic::Outputs::Outcome.register
32
+ Alembic::Outputs::InnerTotal.register
32
33
  end
33
34
  end
34
35
 
36
+ initializer "alembic.question_importer" do |app|
37
+ app.config.to_prepare { require "alembic/question_importer" if defined?(Ixport::Importer) }
38
+ end
39
+
35
40
  initializer "alembic.result_blocks" do |app|
36
41
  app.config.to_prepare do
37
42
  Alembic::Page::ContentBlocks.register
@@ -0,0 +1,17 @@
1
+ module Alembic
2
+ class QuestionImporter < Ixport::Importer
3
+ field :type, label: "Type"
4
+ field :question, label: "Question"
5
+ field :category, label: "Category"
6
+ Flow::UploadedQuestion::ANSWER_FIELDS.each.with_index(1) { |key, number| field key, label: "Answer #{number}" }
7
+ field :right_answer, label: "Right answer"
8
+
9
+ def records
10
+ Flow::UploadedQuestions.new(target)
11
+ end
12
+
13
+ def self.allows?(account, quiz)
14
+ quiz.is_a?(EasyFlow::Definition) && quiz.host == FLOW_HOST.to_s && (Alembic.owner_method.nil? || quiz.owner == account)
15
+ end
16
+ end
17
+ end
@@ -1,3 +1,3 @@
1
1
  module Alembic
2
- VERSION = "0.7.2"
2
+ VERSION = "0.8.0"
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: alembic
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.7.2
4
+ version: 0.8.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - tylercschneider
@@ -57,14 +57,14 @@ dependencies:
57
57
  requirements:
58
58
  - - "~>"
59
59
  - !ruby/object:Gem::Version
60
- version: '0.25'
60
+ version: '0.27'
61
61
  type: :runtime
62
62
  prerelease: false
63
63
  version_requirements: !ruby/object:Gem::Requirement
64
64
  requirements:
65
65
  - - "~>"
66
66
  - !ruby/object:Gem::Version
67
- version: '0.25'
67
+ version: '0.27'
68
68
  description: Alembic builds and runs data-driven interactive diagnostics — scored
69
69
  assessments and branching guides — that place a visitor on an outcome and capture
70
70
  the result. A runtime engine plus a UI builder.
@@ -107,8 +107,11 @@ files:
107
107
  - app/models/alembic/flow/run_summary.rb
108
108
  - app/models/alembic/flow/summaries.rb
109
109
  - app/models/alembic/flow/summary_version.rb
110
+ - app/models/alembic/flow/uploaded_question.rb
111
+ - app/models/alembic/flow/uploaded_questions.rb
110
112
  - app/models/alembic/outputs/band.rb
111
113
  - app/models/alembic/outputs/grouped.rb
114
+ - app/models/alembic/outputs/inner_total.rb
112
115
  - app/models/alembic/outputs/lowest.rb
113
116
  - app/models/alembic/outputs/outcome.rb
114
117
  - app/models/alembic/outputs/percentage.rb
@@ -215,6 +218,7 @@ files:
215
218
  - lib/alembic.rb
216
219
  - lib/alembic/engine.rb
217
220
  - lib/alembic/leftover_stylesheet.rb
221
+ - lib/alembic/question_importer.rb
218
222
  - lib/alembic/version.rb
219
223
  - lib/tasks/alembic_tasks.rake
220
224
  homepage: https://github.com/tylercschneider/alembic