pupilfirst_xapi 0.1.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.
data/README.md ADDED
@@ -0,0 +1,74 @@
1
+ # PupilfirstXapi
2
+
3
+ This gem subscribes to events published by Pupilfirst LMS system,
4
+ builds the XAPI statements and sends them to LRS endpoint defined
5
+ using ENV variables. The list of handled Pupilfirst's events is
6
+ defined [here](/lib/pupilfirst_xapi/statements.rb).
7
+
8
+ ## Installation
9
+ Add this line to your application's Gemfile:
10
+
11
+ ```ruby
12
+ gem 'pupilfirst_xapi'
13
+ ```
14
+
15
+ And then execute:
16
+ ```bash
17
+ $ bundle
18
+ ```
19
+
20
+ Or install it yourself as:
21
+ ```bash
22
+ $ gem install pupilfirst_xapi
23
+ ```
24
+
25
+ ## Usage
26
+ Add application initializer file and setup dependencies required by this gem:
27
+
28
+ ### Define Pupilfirst models repository
29
+ Add this line in gem initializer:
30
+ ```
31
+ PupilfirstXapi.repository = ->(klass, resource_id) { ... }
32
+ ```
33
+
34
+ It could be simple lambda to fetch Pupilfirst's models or
35
+ a class with a `call(klass, repository_id)` method.
36
+
37
+ Arguments:
38
+
39
+ * `klass` - a symbol from a list defined below representing one of the entities
40
+ from Pupilfirst's data model,
41
+ * `resource_id` - an entity id (Application Record id attribute)
42
+
43
+
44
+ Entity types that are used by this gem:
45
+
46
+ * `:course` - Pupilfirst's `Course` class
47
+ * `:target` - Pupilfirst's `Target` class
48
+ * `:user` - Pupilfirst's `User` class
49
+
50
+ The result of the lambda or repository class call method must be
51
+ an Pupilfirst's ActiveRecord object for requested class.
52
+
53
+ ### Define URI builder
54
+ Add this line in gem initializer:
55
+ ```
56
+ PupilfirstXapi.uri_for = ->(obj) { ... }
57
+ ```
58
+
59
+ Arguments:
60
+
61
+ * `obj` - an Pupilfirst's ActiveRecord object
62
+
63
+ The result of the `PupilfirstXapi.uri_for.(obj)` call must be
64
+ the unique URI of the object passed as argument.
65
+
66
+ ### Define LRS endpoint
67
+ Set environment variables:
68
+
69
+ * `LRS_ENDPOINT` - url of LRS server's XAPI endpoint
70
+ * `LRS_KEY` - authentication key
71
+ * `LRS_SECRET` - authentication secter
72
+
73
+ ## License
74
+ The gem is available as open source under the terms of the [GNU GPLv3 License](https://opensource.org/licenses/GPL-3.0).
data/Rakefile ADDED
@@ -0,0 +1,25 @@
1
+ begin
2
+ require 'bundler/setup'
3
+ rescue LoadError
4
+ puts 'You must `gem install bundler` and `bundle install` to run rake tasks'
5
+ end
6
+
7
+ require 'rdoc/task'
8
+
9
+ RDoc::Task.new(:rdoc) do |rdoc|
10
+ rdoc.rdoc_dir = 'rdoc'
11
+ rdoc.title = 'PupilfirstXapi'
12
+ rdoc.options << '--line-numbers'
13
+ rdoc.rdoc_files.include('README.md')
14
+ rdoc.rdoc_files.include('lib/**/*.rb')
15
+ end
16
+
17
+ load 'rails/tasks/statistics.rake'
18
+
19
+ require 'bundler/gem_tasks'
20
+
21
+ require 'rspec/core'
22
+ require 'rspec/core/rake_task'
23
+ RSpec::Core::RakeTask.new(:spec)
24
+
25
+ task :default => :spec
@@ -0,0 +1,20 @@
1
+ require "action_controller/railtie"
2
+ require "active_job/railtie"
3
+ require "growthtribe_xapi"
4
+ require "pupilfirst_xapi/version"
5
+ require "pupilfirst_xapi/outbox"
6
+ require "pupilfirst_xapi/actors"
7
+ require "pupilfirst_xapi/objects"
8
+ require "pupilfirst_xapi/verbs"
9
+ require "pupilfirst_xapi/statements"
10
+
11
+ module PupilfirstXapi
12
+ mattr_accessor :uri_for
13
+ mattr_accessor :repository
14
+
15
+ Statements.subscribe do |event_type|
16
+ ActiveSupport::Notifications.subscribe("#{event_type}.pupilfirst") do |_name, _start, finish, _id, payload|
17
+ Outbox << payload.merge(event_type: event_type, timestamp: finish)
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,7 @@
1
+ module PupilfirstXapi
2
+ module Actors
3
+ def self.agent(actor)
4
+ Xapi.create_agent(agent_type: 'Agent', email: actor.email, name: actor.name)
5
+ end
6
+ end
7
+ end
@@ -0,0 +1,15 @@
1
+ require_relative 'objects/builder'
2
+ require_relative 'objects/course'
3
+ require_relative 'objects/target'
4
+
5
+ module PupilfirstXapi
6
+ module Objects
7
+ def self.course(course, uri)
8
+ Course.new.call(course, uri)
9
+ end
10
+
11
+ def self.target(target, uri)
12
+ Target.new.call(target, uri)
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,24 @@
1
+ module PupilfirstXapi
2
+ module Objects
3
+ class Builder
4
+ def initialize(id:, name:, description:, type:)
5
+ @params = {
6
+ id: id,
7
+ name: name,
8
+ description: description,
9
+ type: type
10
+ }
11
+ end
12
+
13
+ def with_extension(type, value)
14
+ @params[:extensions] ||= {}
15
+ @params[:extensions].merge!({type => value})
16
+ self
17
+ end
18
+
19
+ def call
20
+ Xapi.create_activity(@params)
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,19 @@
1
+ module PupilfirstXapi
2
+ module Objects
3
+ class Course
4
+ def call(course, uri)
5
+ Builder.new(
6
+ id: uri,
7
+ type: 'http://adlnet.gov/expapi/activities/product',
8
+ name: course.name,
9
+ description: course.description
10
+ ).tap do |obj|
11
+ if course.ends_at.present?
12
+ duration = ActiveSupport::Duration.build(course.ends_at - course.created_at).iso8601
13
+ obj.with_extension("http://id.tincanapi.com/extension/planned-duration", duration)
14
+ end
15
+ end.call
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,14 @@
1
+ module PupilfirstXapi
2
+ module Objects
3
+ class Target
4
+ def call(target, uri)
5
+ Builder.new(
6
+ id: uri,
7
+ type: "http://activitystrea.ms/schema/1.0/task",
8
+ name: target.title,
9
+ description: target.description
10
+ ).call
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,66 @@
1
+ require "active_job"
2
+ require 'xapi'
3
+
4
+ module PupilfirstXapi
5
+ class Outbox
6
+ class Job < ActiveJob::Base
7
+ queue_as :default
8
+
9
+ def perform(payload)
10
+ outbox.call(**payload)
11
+ end
12
+
13
+ private
14
+
15
+ def outbox
16
+ Outbox.new(
17
+ lrs: remote_lrs,
18
+ repository: PupilfirstXapi.repository,
19
+ uri_for: PupilfirstXapi.uri_for
20
+ )
21
+ end
22
+
23
+ def remote_lrs
24
+ Xapi.create_remote_lrs(
25
+ end_point: ENV['LRS_ENDPOINT'],
26
+ user_name: ENV['LRS_KEY'],
27
+ password: ENV['LRS_SECRET']
28
+ )
29
+ end
30
+ end
31
+
32
+ class << self
33
+ def <<(payload)
34
+ Outbox::Job.perform_later(payload)
35
+ end
36
+ end
37
+
38
+ def initialize(lrs:, repository:, uri_for:)
39
+ @lrs = lrs
40
+ @repository = repository
41
+ @uri_for = uri_for
42
+ end
43
+
44
+ def call(**payload)
45
+ statement = statement_for(**payload)
46
+ Xapi.post_statement(remote_lrs: @lrs, statement: statement) if statement
47
+ end
48
+
49
+ private
50
+ attr_reader :lrs, :repository, :uri_for
51
+
52
+ def statement_for(event_type:, timestamp:, **args)
53
+ builder_for(event_type).call(**statement_args(**args)).tap do |statement|
54
+ statement&.stamp(id: nil, timestamp: timestamp)
55
+ end
56
+ end
57
+
58
+ def statement_args(**args)
59
+ args.slice(:actor_id, :resource_id)
60
+ end
61
+
62
+ def builder_for(event_type)
63
+ Statements.builder_for(event_type).new(repository, uri_for)
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,22 @@
1
+ require "pupilfirst_xapi/statements/course_completed"
2
+ require "pupilfirst_xapi/statements/course_registered"
3
+ require "pupilfirst_xapi/statements/target_completed"
4
+
5
+ module PupilfirstXapi
6
+ module Statements
7
+ def self.subscribe(&block)
8
+ EVENTS.each_key{|key| block.call(key)}
9
+ end
10
+
11
+ def self.builder_for(event)
12
+ EVENTS.fetch(event)
13
+ end
14
+
15
+ EVENTS = {
16
+ :course_completed => CourseCompleted,
17
+ :student_added => CourseRegistered,
18
+ :submission_graded => TargetCompleted,
19
+ :submission_automatically_verified => TargetCompleted,
20
+ }
21
+ end
22
+ end
@@ -0,0 +1,21 @@
1
+ module PupilfirstXapi
2
+ module Statements
3
+ class CourseCompleted
4
+ def initialize(repository, uri_for)
5
+ @repository = repository
6
+ @uri_for = uri_for
7
+ end
8
+
9
+ def call(actor_id:, resource_id:)
10
+ actor = @repository.call(:user, actor_id)
11
+ course = @repository.call(:course, resource_id)
12
+
13
+ Xapi.create_statement(
14
+ actor: Actors.agent(actor),
15
+ verb: Verbs::COMPLETED,
16
+ object: Objects.course(course, @uri_for.call(course))
17
+ )
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,21 @@
1
+ module PupilfirstXapi
2
+ module Statements
3
+ class CourseRegistered
4
+ def initialize(repository, uri_for)
5
+ @repository = repository
6
+ @uri_for = uri_for
7
+ end
8
+
9
+ def call(actor_id:, resource_id:)
10
+ actor = @repository.call(:user, actor_id)
11
+ course = @repository.call(:course, resource_id)
12
+
13
+ Xapi.create_statement(
14
+ actor: Actors.agent(actor),
15
+ verb: Verbs::REGISTERED,
16
+ object: Objects.course(course, @uri_for.call(course))
17
+ )
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,24 @@
1
+ module PupilfirstXapi
2
+ module Statements
3
+ class TargetCompleted
4
+ def initialize(repository, uri_for)
5
+ @repository = repository
6
+ @uri_for = uri_for
7
+ end
8
+
9
+ def call(actor_id:, resource_id:)
10
+ submission = @repository.call(:timeline_event, resource_id)
11
+ return unless submission.passed?
12
+
13
+ actor = @repository.call(:user, actor_id)
14
+ target = submission.target
15
+
16
+ Xapi.create_statement(
17
+ actor: Actors.agent(actor),
18
+ verb: Verbs::COMPLETED_ASSIGNMENT,
19
+ object: Objects.target(target, @uri_for.call(target))
20
+ )
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,7 @@
1
+ module PupilfirstXapi
2
+ module Verbs
3
+ COMPLETED = Xapi.create_verb(id: 'http://adlnet.gov/expapi/verbs/completed', name: 'completed')
4
+ COMPLETED_ASSIGNMENT = Xapi.create_verb(id: 'https://w3id.org/xapi/dod-isd/verbs/completed-assignment', name: 'completed assignment')
5
+ REGISTERED = Xapi.create_verb(id: 'http://adlnet.gov/expapi/verbs/registered', name: 'registered')
6
+ end
7
+ end
@@ -0,0 +1,3 @@
1
+ module PupilfirstXapi
2
+ VERSION = '0.1.0'
3
+ end
@@ -0,0 +1,111 @@
1
+ require 'rails_helper'
2
+
3
+ RSpec.describe "#xapi", type: :job, perform_jobs: true do
4
+ let(:john) {
5
+ double(:john,
6
+ id: 123,
7
+ name: 'John Doe',
8
+ email: 'john@example.com',
9
+ uri: '/user/123')
10
+ }
11
+ let(:ror_guides) {
12
+ double(:course,
13
+ id: 1234,
14
+ name: 'Ruby on Rails Guides',
15
+ description: 'These guides are designed to make you immediately productive with Rails',
16
+ created_at: Time.new(2021,01,01),
17
+ ends_at: nil,
18
+ uri: 'https://guides.rubyonrails.org/')
19
+ }
20
+ let(:getting_started) {
21
+ double(:target,
22
+ title: 'Getting Started with Rails',
23
+ description: 'This guide covers getting up and running with Ruby on Rails.',
24
+ uri: 'https://guides.rubyonrails.org/getting_started.html')
25
+ }
26
+ let(:good_one) { double(:timeline_event, target: getting_started, passed?: true) }
27
+ let(:bad_one) { double(:timeline_event, target: getting_started, passed?: false) }
28
+
29
+
30
+ let(:models) {
31
+ {
32
+ :user => {
33
+ 123 => john,
34
+ },
35
+ :course => {
36
+ 1234 => ror_guides,
37
+ },
38
+ :timeline_event => {
39
+ 1 => good_one,
40
+ 2 => bad_one,
41
+ }
42
+ }
43
+ }
44
+ let(:repository) { ->(klass, resource_id) { models.dig(klass, resource_id) } }
45
+ let(:uri_for) { ->(obj) { obj.uri } }
46
+
47
+ before do
48
+ PupilfirstXapi.repository = repository
49
+ PupilfirstXapi.uri_for = uri_for
50
+ end
51
+
52
+ def xapi_actor(user)
53
+ {
54
+ objectType: 'Agent',
55
+ name: user.name,
56
+ mbox: "mailto:#{user.email}",
57
+ }
58
+ end
59
+
60
+ def xapi_verb(verb)
61
+ {
62
+ id: verb.id,
63
+ display: verb.display
64
+ }
65
+ end
66
+
67
+ it "#works" do
68
+ timestamp = Time.now
69
+ xapi_request = {
70
+ actor: xapi_actor(john),
71
+ verb: xapi_verb(PupilfirstXapi::Verbs::COMPLETED),
72
+ object: {
73
+ id: ror_guides.uri,
74
+ definition: {
75
+ name: {'en-US' => 'Ruby on Rails Guides'},
76
+ description: {'en-US' => 'These guides are designed to make you immediately productive with Rails'},
77
+ type: 'http://adlnet.gov/expapi/activities/product',
78
+ },
79
+ },
80
+ timestamp: timestamp.iso8601,
81
+ version: '1.0.1',
82
+ }
83
+ request = stub_request(:post, "https://test.lrs/statements")
84
+ .with(
85
+ body: xapi_request.to_json,
86
+ headers: {
87
+ 'Accept'=>'*/*',
88
+ 'Accept-Encoding'=>'gzip;q=1.0,deflate;q=0.6,identity;q=0.3',
89
+ 'Authorization'=>'Basic a2V5OnNlY3JldA==',
90
+ 'Content-Type'=>'application/json',
91
+ 'User-Agent'=>'Faraday v1.3.0',
92
+ 'X-Experience-Api-Version'=>'1.0.1'
93
+ }
94
+ ).to_return(status: 204, body: "", headers: {})
95
+
96
+ allow(Concurrent).to receive(:monotonic_time).and_return(timestamp)
97
+
98
+ ActiveSupport::Notifications.instrument(
99
+ "course_completed.pupilfirst",
100
+ resource_id: ror_guides.id,
101
+ actor_id: john.id,
102
+ )
103
+ expect(PupilfirstXapi::Outbox::Job).to have_been_performed.with({
104
+ event_type: :course_completed,
105
+ actor_id: 123,
106
+ resource_id: 1234,
107
+ timestamp: timestamp,
108
+ })
109
+ expect(request).to have_been_requested
110
+ end
111
+ end