pupilfirst_xapi 0.1.0

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