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.
- checksums.yaml +7 -0
- data/LICENSE +674 -0
- data/README.md +74 -0
- data/Rakefile +25 -0
- data/lib/pupilfirst_xapi.rb +20 -0
- data/lib/pupilfirst_xapi/actors.rb +7 -0
- data/lib/pupilfirst_xapi/objects.rb +15 -0
- data/lib/pupilfirst_xapi/objects/builder.rb +24 -0
- data/lib/pupilfirst_xapi/objects/course.rb +19 -0
- data/lib/pupilfirst_xapi/objects/target.rb +14 -0
- data/lib/pupilfirst_xapi/outbox.rb +66 -0
- data/lib/pupilfirst_xapi/statements.rb +22 -0
- data/lib/pupilfirst_xapi/statements/course_completed.rb +21 -0
- data/lib/pupilfirst_xapi/statements/course_registered.rb +21 -0
- data/lib/pupilfirst_xapi/statements/target_completed.rb +24 -0
- data/lib/pupilfirst_xapi/verbs.rb +7 -0
- data/lib/pupilfirst_xapi/version.rb +3 -0
- data/spec/integration_spec.rb +111 -0
- data/spec/objects/builder_spec.rb +54 -0
- data/spec/outbox_job_spec.rb +15 -0
- data/spec/outbox_spec.rb +71 -0
- data/spec/rails_helper.rb +11 -0
- data/spec/spec_helper.rb +56 -0
- data/spec/statements/course_completed_spec.rb +31 -0
- data/spec/statements/course_redistered_spec.rb +61 -0
- data/spec/statements/target_completed_spec.rb +47 -0
- metadata +156 -0
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,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,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,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
|