rails-xapi 0.1.2

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.
Files changed (53) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +152 -0
  4. data/Rakefile +8 -0
  5. data/app/controllers/rails_xapi/application_controller.rb +7 -0
  6. data/app/helpers/rails_xapi/application_helper.rb +26 -0
  7. data/app/jobs/rails_xapi/application_job.rb +4 -0
  8. data/app/jobs/rails_xapi/create_statement_job.rb +11 -0
  9. data/app/mailers/rails_xapi/application_mailer.rb +6 -0
  10. data/app/models/concerns/serializable.rb +14 -0
  11. data/app/models/rails_xapi/account.rb +27 -0
  12. data/app/models/rails_xapi/activity_definition.rb +143 -0
  13. data/app/models/rails_xapi/actor.rb +227 -0
  14. data/app/models/rails_xapi/application_record.rb +5 -0
  15. data/app/models/rails_xapi/context.rb +163 -0
  16. data/app/models/rails_xapi/context_activity.rb +37 -0
  17. data/app/models/rails_xapi/errors/xapi_error.rb +4 -0
  18. data/app/models/rails_xapi/extension.rb +29 -0
  19. data/app/models/rails_xapi/group_member.rb +21 -0
  20. data/app/models/rails_xapi/interaction_activity.rb +103 -0
  21. data/app/models/rails_xapi/interaction_component.rb +34 -0
  22. data/app/models/rails_xapi/object.rb +150 -0
  23. data/app/models/rails_xapi/result.rb +174 -0
  24. data/app/models/rails_xapi/statement.rb +62 -0
  25. data/app/models/rails_xapi/validators/language_map_validator.rb +64 -0
  26. data/app/models/rails_xapi/verb.rb +260 -0
  27. data/app/services/application_service.rb +16 -0
  28. data/app/services/rails_xapi/query.rb +217 -0
  29. data/app/services/rails_xapi/statement_creator.rb +53 -0
  30. data/app/views/layouts/rails_xapi/application.html.erb +12 -0
  31. data/config/locales/rails_xapi.en.yml +43 -0
  32. data/config/locales/rails_xapi.fr.yml +233 -0
  33. data/config/routes.rb +2 -0
  34. data/db/migrate/20240716144226_create_rails_xapi_actors.rb +16 -0
  35. data/db/migrate/20240716144227_create_rails_xapi_accounts.rb +15 -0
  36. data/db/migrate/20240716144228_create_rails_xapi_verbs.rb +14 -0
  37. data/db/migrate/20240716144229_create_rails_xapi_objects.rb +16 -0
  38. data/db/migrate/20240716144230_create_rails_xapi_activity_definitions.rb +17 -0
  39. data/db/migrate/20240716144231_create_rails_xapi_extensions.rb +13 -0
  40. data/db/migrate/20240716144232_create_rails_xapi_statements.rb +19 -0
  41. data/db/migrate/20240716144233_create_rails_xapi_results.rb +21 -0
  42. data/db/migrate/20240716144234_create_rails_xapi_contexts.rb +23 -0
  43. data/db/migrate/20240716144235_create_rails_xapi_context_activities.rb +16 -0
  44. data/db/migrate/20240716144236_create_rails_xapi_group_members.rb +16 -0
  45. data/db/migrate/20250522093846_create_rails_xapi_interaction_activities.rb +15 -0
  46. data/db/migrate/20250522122830_create_rails_xapi_interaction_component.rb +16 -0
  47. data/lib/rails-xapi/configuration.rb +13 -0
  48. data/lib/rails-xapi/engine.rb +28 -0
  49. data/lib/rails-xapi/version.rb +5 -0
  50. data/lib/rails-xapi.rb +16 -0
  51. data/lib/tasks/auto_annotate_models.rake +62 -0
  52. data/lib/tasks/rails-xapi_tasks.rake +4 -0
  53. metadata +122 -0
@@ -0,0 +1,163 @@
1
+ # frozen_string_literal: true
2
+
3
+ # The optional property context.
4
+ # See: https://github.com/adlnet/xAPI-Spec/blob/master/xAPI-Data.md#246-context
5
+ class RailsXapi::Context < ApplicationRecord
6
+ include Serializable
7
+
8
+ belongs_to :instructor, class_name: "RailsXapi::Actor", optional: true
9
+ belongs_to :team, class_name: "RailsXapi::Actor", optional: true
10
+ belongs_to :statement, class_name: "RailsXapi::Statement", dependent: :destroy
11
+ belongs_to :statement_ref,
12
+ class_name: "RailsXapi::Statement",
13
+ foreign_key: :statement_ref,
14
+ optional: true
15
+ has_many :context_activities, dependent: :destroy
16
+ has_many :extensions, as: :extendable, dependent: :destroy
17
+
18
+ before_validation :validate_platform
19
+
20
+ def contextActivities=(context_activities_hash)
21
+ context_activities_hash.each do |activity_type, activities|
22
+ activities.each do |activity|
23
+ # Create the object and update it if necessary.
24
+ object =
25
+ RailsXapi::Object.find_or_create(activity) do
26
+ object.activity_definition = activity[:definition] if activity[
27
+ :definition
28
+ ].present?
29
+ end
30
+
31
+ object.update(activity)
32
+ # Create the ContextActivity object.
33
+ context_activity =
34
+ RailsXapi::ContextActivity.new(
35
+ activity_type: activity_type.to_s,
36
+ object: object
37
+ )
38
+ context_activities << context_activity
39
+ end
40
+ end
41
+ end
42
+
43
+ # Set the instructor value and create the actor if provided.
44
+ def instructor=(value)
45
+ return if value.blank?
46
+
47
+ actor = find_or_create_actor_with_account(value)
48
+ super(actor) if actor.present?
49
+ end
50
+
51
+ # Set the team value and create the actor if provided.
52
+ def team=(value)
53
+ return if value.blank?
54
+
55
+ actor = find_or_create_actor_with_account(value, "Group")
56
+ super(actor) if actor.present?
57
+ end
58
+
59
+ # Set the statement_ref value if provided.
60
+ # RailsXapi::Context needs a setter to save the "statement" data. However, it also
61
+ # belongs to a RailsXapi::Statement. Therefore, we use the attribute :statement_ref.
62
+ def statement=(value)
63
+ return unless value.is_a?(Hash) && value[:objectType] == "StatementRef"
64
+
65
+ id = value[:id]
66
+ return if id.nil?
67
+
68
+ statement_row = RailsXapi::Statement.find_by(id: id)
69
+ self[:statement_ref] = statement_row.id if statement_row&.id.present?
70
+ end
71
+
72
+ def extensions=(extensions_data)
73
+ unless extensions_data.is_a?(Hash)
74
+ raise RailsXapi::Errors::XapiError,
75
+ I18n.t(
76
+ "rails_xapi.errors.attribute_must_be_a_hash",
77
+ name: "extensions"
78
+ )
79
+ end
80
+
81
+ extensions_data.each do |iri, data|
82
+ extensions.build(iri: iri, value: serialized_value(data))
83
+ end
84
+ end
85
+
86
+ def as_json
87
+ context_attributes = {}
88
+ context_attributes[:registration] = registration if registration.present?
89
+ context_attributes[:instructor] = instructor if instructor.present?
90
+ context_attributes[:team] = team if team.present?
91
+
92
+ if context_activities.present?
93
+ grouped = context_activities.group_by(&:activity_type)
94
+ # convert string keys to symbols
95
+ context_attributes[:contextActivities] = grouped
96
+ .transform_keys(&:to_sym)
97
+ .transform_values { |activities| activities.map(&:as_json) }
98
+ end
99
+
100
+ context_attributes[
101
+ :statement
102
+ ] = statement_ref.id if statement_ref&.id.present?
103
+
104
+ context_attributes
105
+ end
106
+
107
+ private
108
+
109
+ def find_or_create_actor_with_account(value, object_type = nil)
110
+ home_page = value.dig(:account, :homePage)
111
+ existing_account =
112
+ RailsXapi::Account.find_by(home_page: home_page) if home_page.present?
113
+
114
+ # Set the params to search an existing row.
115
+ actor_params = {
116
+ mbox: value[:mbox],
117
+ mbox_sha1sum: value[:mbox_sha1sum],
118
+ openid: value[:openid]
119
+ }
120
+ actor_params[:account] = existing_account if existing_account.present?
121
+
122
+ RailsXapi::Actor.find_or_create_by(actor_params) do |actor|
123
+ actor.name = value[:name] if value[:name].present?
124
+ actor.object_type = object_type if object_type.present?
125
+ actor.account = RailsXapi::Account.new(value[:account]) if value[
126
+ :account
127
+ ].present?
128
+ end
129
+ end
130
+
131
+ # The "platform" property MUST only be used if the Statement's Object is an Activity.
132
+ # See: https://github.com/adlnet/xAPI-Spec/blob/master/xAPI-Data.md#requirements-10
133
+ def validate_platform
134
+ ref_statement =
135
+ statement_ref ? RailsXapi::Statement.find_by(id: statement_ref) : nil
136
+
137
+ ref_statement ||= statement_ref
138
+
139
+ self[:platform] = nil if !ref_statement&.object&.activity?
140
+ end
141
+ end
142
+
143
+ # == Schema Information
144
+ #
145
+ # Table name: rails_xapi_contexts
146
+ #
147
+ # id :integer not null, primary key
148
+ # language :string
149
+ # platform :string
150
+ # registration :string
151
+ # revision :string
152
+ # statement_ref :bigint
153
+ # instructor_id :bigint
154
+ # statement_id :bigint not null
155
+ # team_id :bigint
156
+ #
157
+ # Indexes
158
+ #
159
+ # index_rails_xapi_contexts_on_instructor_id (instructor_id)
160
+ # index_rails_xapi_contexts_on_statement_id (statement_id)
161
+ # index_rails_xapi_contexts_on_statement_ref (statement_ref)
162
+ # index_rails_xapi_contexts_on_team_id (team_id)
163
+ #
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ # The optional context activity.
4
+ # See: https://github.com/adlnet/xAPI-Spec/blob/master/xAPI-Data.md#246-context
5
+ class RailsXapi::ContextActivity < ApplicationRecord
6
+ belongs_to :context, class_name: "RailsXapi::Context"
7
+ belongs_to :object, class_name: "RailsXapi::Object"
8
+
9
+ validates :activity_type,
10
+ presence: true,
11
+ inclusion: {
12
+ in: %w[parent grouping category other]
13
+ }
14
+
15
+ def as_json
16
+ { id: object_id, objectType: object&.object_type }.tap do |hash|
17
+ hash[
18
+ :definition
19
+ ] = object&.definition&.as_json if object&.definition.present?
20
+ end
21
+ end
22
+ end
23
+
24
+ # == Schema Information
25
+ #
26
+ # Table name: rails_xapi_context_activities
27
+ #
28
+ # id :integer not null, primary key
29
+ # activity_type :string not null
30
+ # context_id :bigint not null
31
+ # object_id :string not null
32
+ #
33
+ # Indexes
34
+ #
35
+ # index_rails_xapi_context_activities_on_context_id (context_id)
36
+ # index_rails_xapi_context_activities_on_object_id (object_id)
37
+ #
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ class RailsXapi::Errors::XapiError < StandardError
4
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ # The object optional activity definition's extensions.
4
+ # See: https://github.com/adlnet/xAPI-Spec/blob/master/xAPI-Data.md#41-extensions
5
+ class RailsXapi::Extension < ApplicationRecord
6
+ belongs_to :extendable, polymorphic: true
7
+
8
+ validates :iri, presence: true
9
+ validates :value, presence: true
10
+
11
+ def as_json
12
+ { iri => value }.compact
13
+ end
14
+ end
15
+
16
+ # == Schema Information
17
+ #
18
+ # Table name: rails_xapi_extensions
19
+ #
20
+ # id :integer not null, primary key
21
+ # extendable_type :string
22
+ # iri :string not null
23
+ # value :text not null
24
+ # extendable_id :integer
25
+ #
26
+ # Indexes
27
+ #
28
+ # index_rails_xapi_extensions_on_extendable (extendable_type,extendable_id)
29
+ #
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ class RailsXapi::GroupMember < ApplicationRecord
4
+ belongs_to :group, class_name: "RailsXapi::Actor", dependent: :destroy
5
+ belongs_to :actor, class_name: "RailsXapi::Actor", dependent: :destroy
6
+ end
7
+
8
+ # == Schema Information
9
+ #
10
+ # Table name: rails_xapi_group_members
11
+ #
12
+ # id :integer not null, primary key
13
+ # created_at :datetime not null
14
+ # actor_id :bigint not null
15
+ # group_id :bigint not null
16
+ #
17
+ # Indexes
18
+ #
19
+ # index_rails_xapi_group_members_on_actor_id (actor_id)
20
+ # index_rails_xapi_group_members_on_group_id (group_id)
21
+ #
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ # The optional structure for interactions or assessments.
4
+ # See: https://github.com/adlnet/xAPI-Spec/blob/master/xAPI-Data.md#interaction-activities
5
+ class RailsXapi::InteractionActivity < ApplicationRecord
6
+ belongs_to :activity_definition, class_name: "RailsXapi::ActivityDefinition"
7
+ has_many :interaction_components,
8
+ foreign_key: :interaction_activity_id,
9
+ dependent: :destroy
10
+
11
+ INTERACTION_COMPONENT_TYPES = %w[scale choices source target steps]
12
+ INTERACTION_KEYS =
13
+ %w[interactionType correctResponsesPattern] + INTERACTION_COMPONENT_TYPES
14
+
15
+ validates :interaction_type,
16
+ presence: true,
17
+ inclusion: {
18
+ in: %w[
19
+ true-false
20
+ choice
21
+ fill-in
22
+ long-fill-in
23
+ matching
24
+ performance
25
+ sequencing
26
+ likert
27
+ numeric
28
+ other
29
+ ]
30
+ }
31
+
32
+ attribute :correct_responses_pattern, :string, default: -> { [].to_json }
33
+
34
+ def assign_interaction_components(interaction_attrs)
35
+ return unless interaction_attrs.present?
36
+
37
+ interaction_components.where(
38
+ component_type: INTERACTION_COMPONENT_TYPES
39
+ ).destroy_all
40
+
41
+ INTERACTION_COMPONENT_TYPES.each do |component_type|
42
+ Array(interaction_attrs[component_type]).each do |data|
43
+ interaction_components.build(
44
+ component_type: component_type,
45
+ component_id: data["id"],
46
+ description: data["description"].to_json,
47
+ interaction_activity: self
48
+ )
49
+ end
50
+ end
51
+ end
52
+
53
+ def correct_responses_pattern
54
+ JSON.parse(super || "[]")
55
+ end
56
+
57
+ def correct_responses_pattern=(value)
58
+ super(value.to_json)
59
+ end
60
+
61
+ def components_by_interaction_type
62
+ case interaction_type
63
+ when "likert"
64
+ { "scale" => component_hash }
65
+ when "choice"
66
+ { "choice" => component_hash }
67
+ else
68
+ {}
69
+ end
70
+ end
71
+
72
+ def as_json
73
+ {
74
+ interactionType: interaction_type,
75
+ correctResponsesPattern: correct_responses_pattern
76
+ }.merge(components_by_interaction_type)
77
+ end
78
+
79
+ private
80
+
81
+ def component_hash
82
+ interaction_components.map do |component|
83
+ {
84
+ "id" => component.component_id,
85
+ "description" => component.parsed_description
86
+ }
87
+ end
88
+ end
89
+ end
90
+
91
+ # == Schema Information
92
+ #
93
+ # Table name: rails_xapi_interaction_activities
94
+ #
95
+ # id :integer not null, primary key
96
+ # correct_responses_pattern :text
97
+ # interaction_type :string not null
98
+ # activity_definition_id :bigint not null
99
+ #
100
+ # Indexes
101
+ #
102
+ # idx_on_activity_definition_id_0cc615114b (activity_definition_id)
103
+ #
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ # The optional structure for interactions or assessments.
4
+ # See: https://github.com/adlnet/xAPI-Spec/blob/master/xAPI-Data.md#interaction-components
5
+ class RailsXapi::InteractionComponent < ApplicationRecord
6
+ belongs_to :interaction_activity, class_name: "RailsXapi::InteractionActivity"
7
+
8
+ validates_with RailsXapi::Validators::LanguageMapValidator,
9
+ attributes: %i[description]
10
+
11
+ def parsed_description
12
+ return {} unless description.present?
13
+ begin
14
+ JSON.parse(description)
15
+ rescue StandardError
16
+ {}
17
+ end
18
+ end
19
+ end
20
+
21
+ # == Schema Information
22
+ #
23
+ # Table name: rails_xapi_interaction_components
24
+ #
25
+ # id :integer not null, primary key
26
+ # component_type :string not null
27
+ # description :text
28
+ # component_id :string not null
29
+ # interaction_activity_id :bigint not null
30
+ #
31
+ # Indexes
32
+ #
33
+ # idx_on_interaction_activity_id_863e21bede (interaction_activity_id)
34
+ #
@@ -0,0 +1,150 @@
1
+ # frozen_string_literal: true
2
+
3
+ # The Object defines the thing that was acted on.
4
+ # See: https://github.com/adlnet/xAPI-Spec/blob/master/xAPI-Data.md#244-object
5
+ # The Object of a Statement can be an Activity, Agent/Group, SubStatement, or Statement Reference.
6
+ class RailsXapi::Object < ApplicationRecord
7
+ OBJECT_TYPES = %w[Activity Agent Group SubStatement StatementRef]
8
+
9
+ attr_accessor :objectType,
10
+ :actor,
11
+ :verb,
12
+ :object,
13
+ :result,
14
+ :context,
15
+ :timestamp
16
+
17
+ has_one :definition,
18
+ class_name: "RailsXapi::ActivityDefinition",
19
+ dependent: :destroy
20
+ has_many :statements, class_name: "RailsXapi::Statement", dependent: :nullify
21
+ belongs_to :statement, class_name: "RailsXapi::Statement", optional: true
22
+
23
+ validates :id, presence: true
24
+ validates :object_type, presence: true, inclusion: { in: OBJECT_TYPES }
25
+ validates :statement, presence: true, if: -> { object_type == "SubStatement" }
26
+ validates :actor, presence: true, if: -> { object_type == "SubStatement" }
27
+ validates :object, presence: true, if: -> { object_type == "SubStatement" }
28
+ validates :verb, presence: true, if: -> { object_type == "SubStatement" }
29
+
30
+ before_validation :set_defaults, :create_statement_for_substatement
31
+
32
+ accepts_nested_attributes_for :definition
33
+
34
+ def definition=(definition_hash)
35
+ return unless definition_hash.present?
36
+
37
+ # Build or create the associated object.
38
+ build_definition if definition.nil?
39
+ definition.assign_from_json_definition(definition_hash)
40
+ end
41
+
42
+ # Find an Object by its id or create a new one.
43
+ #
44
+ # @param [Hash] attributes The attributes of the requested object.
45
+ # @return [RailsXapi::Object] The found or created object.
46
+ def self.find_or_create(attributes)
47
+ find_by(id: attributes[:id]) || create(attributes)
48
+ end
49
+
50
+ # Update the ActivityDefinition if it's existing.
51
+ def update_definition(definition_data)
52
+ return unless definition_data.present?
53
+
54
+ definition = self.definition || create_definition
55
+ definition.assign_from_json_definition(definition_data)
56
+ definition.save!
57
+ end
58
+
59
+ def activity?
60
+ return true if object_type == "Activity"
61
+
62
+ false
63
+ end
64
+
65
+ def as_json
66
+ { id: id, objectType: object_type }.tap do |hash|
67
+ hash[:definition] = definition.as_json if definition.present?
68
+ end
69
+ end
70
+
71
+ private
72
+
73
+ def set_defaults
74
+ self.object_type ||= objectType.presence || "Activity"
75
+
76
+ if new_record? && object_type == "SubStatement"
77
+ self.actor = actor
78
+ self.verb = verb
79
+ self.object = object
80
+ self.result = result
81
+ self.context = context
82
+ self.timestamp = timestamp
83
+ end
84
+ end
85
+
86
+ def create_statement_for_substatement
87
+ return unless object_type == "SubStatement" && statement.nil?
88
+
89
+ # Generate a random primary key in place of the object ID
90
+ self.id = Digest::SHA1.hexdigest([Time.zone.now, rand(111..999)].join)
91
+
92
+ # Then, create the substatement
93
+ substatement_actor = create_or_find_actor
94
+ substatement_verb = create_or_find_verb
95
+ substatement_object = create_or_find_object
96
+ substatement_result = create_result
97
+ substatement_context = create_context
98
+
99
+ self.statement =
100
+ RailsXapi::Statement.create!(
101
+ actor: substatement_actor,
102
+ verb: substatement_verb,
103
+ object: substatement_object,
104
+ result: substatement_result,
105
+ context: substatement_context,
106
+ timestamp: timestamp
107
+ )
108
+ end
109
+
110
+ def create_or_find_actor
111
+ if actor.blank?
112
+ raise RailsXapi::Errors::XapiError,
113
+ I18n.t("rails_xapi.errors.missing_actor")
114
+ end
115
+
116
+ RailsXapi::Actor.by_iri_or_create(actor)
117
+ end
118
+
119
+ def create_or_find_verb
120
+ RailsXapi::Verb.find_or_create_by(id: verb[:id]) { |v| v.attributes = verb }
121
+ end
122
+
123
+ def create_or_find_object
124
+ RailsXapi::Object.find_or_create_by(id: object[:id]) do |o|
125
+ o.attributes = object
126
+ end
127
+ end
128
+
129
+ def create_result
130
+ RailsXapi::Result.new(result) if result.present?
131
+ end
132
+
133
+ def create_context
134
+ RailsXapi::Context.new(context)
135
+ end
136
+ end
137
+
138
+ # == Schema Information
139
+ #
140
+ # Table name: rails_xapi_objects
141
+ #
142
+ # id :string not null, primary key
143
+ # object_type :string not null
144
+ # statement_id :bigint
145
+ #
146
+ # Indexes
147
+ #
148
+ # index_rails_xapi_objects_on_id (id) UNIQUE
149
+ # index_rails_xapi_objects_on_statement_id (statement_id)
150
+ #