pupilfirst_xapi 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- 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
|