canvas-workflow 0.5.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/CHANGELOG.md +27 -0
- data/LICENSE +21 -0
- data/README.md +69 -0
- data/_includes/newline +4 -0
- data/_layouts/assignment.html +20 -0
- data/_layouts/page.html +23 -0
- data/_layouts/syllabus.html +6 -0
- data/assets/_config.yml +41 -0
- data/bin/canvas +5 -0
- data/lib/canvas-workflow.rb +6 -0
- data/lib/canvas/workflow.rb +78 -0
- data/lib/canvas/workflow/cli.rb +24 -0
- data/lib/canvas/workflow/cli/deploy.rb +79 -0
- data/lib/canvas/workflow/cli/jekyll.rb +28 -0
- data/lib/canvas/workflow/cli/push.rb +55 -0
- data/lib/canvas/workflow/client.rb +13 -0
- data/lib/canvas/workflow/pandarus.rb +88 -0
- data/lib/canvas/workflow/tags.rb +10 -0
- data/lib/canvas/workflow/tags/assignment.rb +28 -0
- data/lib/canvas/workflow/tags/file.rb +35 -0
- data/lib/canvas/workflow/tags/gist.rb +20 -0
- data/lib/canvas/workflow/tags/icon.rb +20 -0
- data/lib/canvas/workflow/travis.rb +112 -0
- data/lib/canvas/workflow/version.rb +7 -0
- data/spec/assignment_spec.rb +63 -0
- data/spec/file_spec.rb +108 -0
- data/spec/fixtures/assignment.json +191 -0
- data/spec/fixtures/doesnotexist.json +1 -0
- data/spec/fixtures/file.json +28 -0
- data/spec/fixtures/nested.json +68 -0
- data/spec/fixtures/root.json +24 -0
- data/spec/gist_spec.rb +29 -0
- data/spec/icon_spec.rb +29 -0
- data/spec/spec_helper.rb +51 -0
- metadata +177 -0
@@ -0,0 +1,55 @@
|
|
1
|
+
module Canvas
|
2
|
+
module Workflow
|
3
|
+
module CLI
|
4
|
+
class Push < Thor
|
5
|
+
desc "push", "Push changes in Canvas Workflow project since last successful Travis build"
|
6
|
+
def push
|
7
|
+
# upload files
|
8
|
+
# FIXME This assumes a unix-like environment which uses backslash as
|
9
|
+
# file separator.
|
10
|
+
Dir.glob('files/**/*', File::FNM_DOTMATCH).select do |file|
|
11
|
+
push?(file) && (Travis.created?(file) || Travis.modified?(file))
|
12
|
+
end.each do |file|
|
13
|
+
puts "=> uploading #{file}"
|
14
|
+
|
15
|
+
content = {
|
16
|
+
'name' => File.basename(file),
|
17
|
+
'size' => File.size(file),
|
18
|
+
'parent_folder_path' => File.dirname(file).sub('files', '')
|
19
|
+
}
|
20
|
+
|
21
|
+
Workflow.client.upload_file(course, file, content)
|
22
|
+
end
|
23
|
+
|
24
|
+
# upload assignments
|
25
|
+
# FIXME this only compacts one level
|
26
|
+
Dir.glob('assignments/*.md').select do |md_file|
|
27
|
+
push?(md_file) && Travis.created?(md_file)
|
28
|
+
end.each do |md_file|
|
29
|
+
content = YAML.load_file(md_file)
|
30
|
+
|
31
|
+
puts "=> creating #{content['title']}"
|
32
|
+
|
33
|
+
Workflow.client.create_assignment(course, content['title'])
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
default_task :push
|
38
|
+
|
39
|
+
private
|
40
|
+
|
41
|
+
def course
|
42
|
+
@course ||= Workflow.config['course']
|
43
|
+
end
|
44
|
+
|
45
|
+
def push?(file)
|
46
|
+
!File.directory?(file) &&
|
47
|
+
(!Workflow.excluded?(file) || Workflow.included?(file))
|
48
|
+
end
|
49
|
+
end
|
50
|
+
|
51
|
+
desc("push", "Push changes in Canvas Workflow project since last successful Travis build")
|
52
|
+
subcommand "push", Push
|
53
|
+
end
|
54
|
+
end
|
55
|
+
end
|
@@ -0,0 +1,13 @@
|
|
1
|
+
require 'pandarus' # canvas lms api
|
2
|
+
|
3
|
+
module Canvas
|
4
|
+
module Workflow
|
5
|
+
class Client < Pandarus::Client
|
6
|
+
def initialize(config)
|
7
|
+
token = ENV['CANVAS_API_TOKEN']
|
8
|
+
prefix = config['prefix'] + '/api'
|
9
|
+
super(prefix: prefix, token: token)
|
10
|
+
end
|
11
|
+
end
|
12
|
+
end
|
13
|
+
end
|
@@ -0,0 +1,88 @@
|
|
1
|
+
require 'pandarus' # canvas lms api
|
2
|
+
require 'rest-client' # multi-part send
|
3
|
+
|
4
|
+
# monkey-patch incorrect/incomplete implementations
|
5
|
+
module Pandarus
|
6
|
+
class V1
|
7
|
+
# incomplete: added additional parameter path
|
8
|
+
def resolve_path_courses(course_id, path, opts={})
|
9
|
+
query_param_keys = [
|
10
|
+
]
|
11
|
+
|
12
|
+
form_param_keys = [
|
13
|
+
]
|
14
|
+
|
15
|
+
# verify existence of params
|
16
|
+
raise "course_id is required" if course_id.nil?
|
17
|
+
raise "path is required" if path.nil?
|
18
|
+
# set default values and merge with input
|
19
|
+
options = underscored_merge_opts(opts,
|
20
|
+
:course_id => course_id,
|
21
|
+
:path => path
|
22
|
+
)
|
23
|
+
|
24
|
+
# resource path
|
25
|
+
path = path_replace("/v1/courses/{course_id}/folders/by_path/{path}/",
|
26
|
+
:course_id => course_id, :path => path)
|
27
|
+
headers = nil
|
28
|
+
form_params = select_params(options, form_param_keys)
|
29
|
+
query_params = select_query_params(options, query_param_keys)
|
30
|
+
|
31
|
+
RemoteCollection.new(connection, Folder, path, query_params)
|
32
|
+
end
|
33
|
+
|
34
|
+
# incomplete: added additional parameter file
|
35
|
+
def upload_file(course_id, file, opts={})
|
36
|
+
query_param_keys = [
|
37
|
+
]
|
38
|
+
|
39
|
+
form_param_keys = [
|
40
|
+
:name,
|
41
|
+
:size,
|
42
|
+
:content_type,
|
43
|
+
:parent_folder_id,
|
44
|
+
:parent_folder_path,
|
45
|
+
:on_duplicate
|
46
|
+
]
|
47
|
+
|
48
|
+
# verify existence of params
|
49
|
+
raise "course_id is required" if course_id.nil?
|
50
|
+
raise "file is required" if file.nil?
|
51
|
+
# set default values and merge with input
|
52
|
+
options = underscored_merge_opts(opts,
|
53
|
+
:course_id => course_id
|
54
|
+
)
|
55
|
+
|
56
|
+
# resource path
|
57
|
+
path = path_replace("/v1/courses/{course_id}/files",
|
58
|
+
:course_id => course_id)
|
59
|
+
headers = nil
|
60
|
+
form_params = select_params(options, form_param_keys)
|
61
|
+
query_params = select_query_params(options, query_param_keys)
|
62
|
+
|
63
|
+
# initiate file upload
|
64
|
+
response = mixed_request(:post, path, query_params, form_params, headers)
|
65
|
+
|
66
|
+
# add file to form data
|
67
|
+
response['upload_params']['file'] = ::File.new(file, "rb")
|
68
|
+
|
69
|
+
# complete file upload using rest-client api and the response to the
|
70
|
+
# original request
|
71
|
+
RestClient.post(response['upload_url'], response['upload_params'])
|
72
|
+
end
|
73
|
+
|
74
|
+
protected
|
75
|
+
# incorrect: fixes instructure/pandarus#28
|
76
|
+
def dot_flatten_recur(hash)
|
77
|
+
hash.map do |k1, v1|
|
78
|
+
if v1.is_a?(Hash)
|
79
|
+
dot_flatten_recur(v1).map do |k2, v2|
|
80
|
+
["#{k1}.#{k2}", v2]
|
81
|
+
end
|
82
|
+
else
|
83
|
+
[[k1, v1]]
|
84
|
+
end
|
85
|
+
end.flatten(1)
|
86
|
+
end
|
87
|
+
end
|
88
|
+
end
|
@@ -0,0 +1,28 @@
|
|
1
|
+
module Canvas
|
2
|
+
module Workflow
|
3
|
+
module Tags
|
4
|
+
class AssignmentTag < Liquid::Tag
|
5
|
+
def initialize(tag_name, text, tokens)
|
6
|
+
super
|
7
|
+
end
|
8
|
+
|
9
|
+
def render(context)
|
10
|
+
config = context.registers[:site].config['canvas']
|
11
|
+
title = context.environments.first['page']['title']
|
12
|
+
course = config['course']
|
13
|
+
client = Canvas::Workflow::Client.new(config)
|
14
|
+
|
15
|
+
assignments = client.list_assignments(course, :search_term => title).to_a
|
16
|
+
|
17
|
+
raise ArgumentError.new("Assignment does not exist") if assignments.empty?
|
18
|
+
|
19
|
+
# return the first, which /should/ be the shortest length string, so
|
20
|
+
# first lexicographically
|
21
|
+
assignments.first[:id]
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
Liquid::Template.register_tag('assignment', Canvas::Workflow::Tags::AssignmentTag)
|
@@ -0,0 +1,35 @@
|
|
1
|
+
module Canvas
|
2
|
+
module Workflow
|
3
|
+
module Tags
|
4
|
+
class FileTag < Liquid::Tag
|
5
|
+
def initialize(tag_name, text, tokens)
|
6
|
+
super
|
7
|
+
@path = text.strip
|
8
|
+
end
|
9
|
+
|
10
|
+
def render(context)
|
11
|
+
config = context.registers[:site].config['canvas']
|
12
|
+
course = config['course']
|
13
|
+
client = Canvas::Workflow::Client.new(config)
|
14
|
+
|
15
|
+
dir = File.dirname(@path)
|
16
|
+
file = File.basename(@path)
|
17
|
+
folders = client.resolve_path_courses(course, dir).to_a
|
18
|
+
|
19
|
+
raise ArgumentError.new("Path does not exist") if folders.empty?
|
20
|
+
|
21
|
+
folder = folders.last
|
22
|
+
files = client.list_files_folders(folder[:id], :search_term => file).to_a
|
23
|
+
|
24
|
+
raise ArgumentError.new("File does not exist") if files.empty?
|
25
|
+
|
26
|
+
# return the first, which /should/ be the shortest length string, so
|
27
|
+
# first lexicographically
|
28
|
+
files.first[:url]
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
Liquid::Template.register_tag('file', Canvas::Workflow::Tags::FileTag)
|
@@ -0,0 +1,20 @@
|
|
1
|
+
module Canvas
|
2
|
+
module Workflow
|
3
|
+
module Tags
|
4
|
+
class GistTag < Liquid::Tag
|
5
|
+
def initialize(tag_name, text, tokens)
|
6
|
+
raise ArgumentError.new("Cannot have empty gist") if text.empty?
|
7
|
+
|
8
|
+
super
|
9
|
+
@gist = text.strip
|
10
|
+
end
|
11
|
+
|
12
|
+
def render(context)
|
13
|
+
"<p><iframe style=\"width: 100%; height: 400px;\" title=\"GitHub gist\" src=\"https://www.edu-apps.org/tools/github/github_summary_gist.html\##{@gist}\" width=\"100%\" height=\"400\" allowfullscreen=\"allowfullscreen\" webkitallowfullscreen=\"webkitallowfullscreen\" mozallowfullscreen=\"mozallowfullscreen\"></iframe></p>"
|
14
|
+
end
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
19
|
+
|
20
|
+
Liquid::Template.register_tag('gist', Canvas::Workflow::Tags::GistTag)
|
@@ -0,0 +1,20 @@
|
|
1
|
+
module Canvas
|
2
|
+
module Workflow
|
3
|
+
module Tags
|
4
|
+
class IconTag < Liquid::Tag
|
5
|
+
def initialize(tag_name, text, tokens)
|
6
|
+
raise ArgumentError.new("Cannot have empty icon") if text.empty?
|
7
|
+
|
8
|
+
super
|
9
|
+
@icon = text.strip
|
10
|
+
end
|
11
|
+
|
12
|
+
def render(context)
|
13
|
+
"<i class=\"icon-#{@icon}\"></i>"
|
14
|
+
end
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
19
|
+
|
20
|
+
Liquid::Template.register_tag('icon', Canvas::Workflow::Tags::IconTag)
|
@@ -0,0 +1,112 @@
|
|
1
|
+
require 'travis' # travis-ci api
|
2
|
+
|
3
|
+
module Canvas
|
4
|
+
module Workflow
|
5
|
+
module Travis
|
6
|
+
# Has +file+ been removed since the last successful build?
|
7
|
+
#
|
8
|
+
# @!macro [new] undefined
|
9
|
+
# @note Behavior is undefined if the reason that there is no previous
|
10
|
+
# successful build is because it was struck from the git history. If
|
11
|
+
# this is the case, then there is no reliable way to determine which
|
12
|
+
# files have been changed without brute-force checking every file
|
13
|
+
# against the Canvas site.
|
14
|
+
# @internal In the current logic, no files will be reported as having
|
15
|
+
# been removed.
|
16
|
+
#
|
17
|
+
# @param file [String] a file name
|
18
|
+
# @return [Boolean] true if the file has been removed since the last
|
19
|
+
# successful build.
|
20
|
+
#
|
21
|
+
# @bug The orignal paths of those files that have been renamed will not
|
22
|
+
# be identified as having been removed.
|
23
|
+
# @internal This is due to --diff-filter=D only returning those files
|
24
|
+
# that have been deleted. --diff-filter=DR is incorrect because R
|
25
|
+
# lists the new paths. It is unclear at this time how to get the
|
26
|
+
# original paths of such files.
|
27
|
+
# @bug This will not recursively identify empty directories.
|
28
|
+
def self.removed?(file)
|
29
|
+
if commit_sha.nil?
|
30
|
+
# no files have been removed, since there is no previous successful
|
31
|
+
# build on this branch
|
32
|
+
else
|
33
|
+
# get the list of files that have been deleted since the last passed
|
34
|
+
# commit
|
35
|
+
@removed ||= `git diff --diff-filter=D --name-only #{commit_sha}`
|
36
|
+
exit($?.exitstatus) if $?.exitstatus != 0
|
37
|
+
end
|
38
|
+
|
39
|
+
# check if the param file has been removed
|
40
|
+
@removed.include?(file)
|
41
|
+
end
|
42
|
+
|
43
|
+
# Has +file+ been created since the last successful build?
|
44
|
+
#
|
45
|
+
# @!macro undefined
|
46
|
+
# @internal In the current logic, all files will be reported as having
|
47
|
+
# been created.
|
48
|
+
#
|
49
|
+
# @param file [String] a file name
|
50
|
+
# @return [Boolean] true if the file has been created since the last
|
51
|
+
# successful build.
|
52
|
+
def self.created?(file)
|
53
|
+
if commit_sha.nil?
|
54
|
+
# get the list of all files on this branch being tracked by git
|
55
|
+
@created ||= `git ls-tree -r #{branch} --name-only`
|
56
|
+
else
|
57
|
+
# get the list of files that have been created since the last passed
|
58
|
+
# commit
|
59
|
+
@created ||= `git diff --diff-filter=AR --name-only #{commit_sha}`
|
60
|
+
end
|
61
|
+
exit($?.exitstatus) if $?.exitstatus != 0
|
62
|
+
|
63
|
+
# check if the param file has been created
|
64
|
+
@created.include?(file)
|
65
|
+
end
|
66
|
+
|
67
|
+
# Has +file+ been modified since the last successful build?
|
68
|
+
#
|
69
|
+
# @!macro undefined
|
70
|
+
# @internal In the current logic, all files will be reported as having
|
71
|
+
# been modified.
|
72
|
+
#
|
73
|
+
# @param file [String] a file name
|
74
|
+
# @return [Boolean] true if the file has been modified since the last
|
75
|
+
# successful build.
|
76
|
+
def self.modified?(file)
|
77
|
+
if commit_sha.nil?
|
78
|
+
# get the list of all files on this branch being tracked by git
|
79
|
+
@modified ||= `git ls-tree -r #{branch} --name-only`
|
80
|
+
else
|
81
|
+
# get the list of files that have been modified since the last
|
82
|
+
# passed commit
|
83
|
+
@modified ||= `git diff --diff-filter=M --name-only #{commit_sha}`
|
84
|
+
end
|
85
|
+
exit($?.exitstatus) if $?.exitstatus != 0
|
86
|
+
|
87
|
+
# check if the param file has been modified
|
88
|
+
@modified.include?(file)
|
89
|
+
end
|
90
|
+
|
91
|
+
private
|
92
|
+
|
93
|
+
def self.branch
|
94
|
+
@branch ||= ENV['TRAVIS_BRANCH']
|
95
|
+
end
|
96
|
+
|
97
|
+
def self.commit_sha
|
98
|
+
return @commit_sha unless @commit_sha.nil?
|
99
|
+
|
100
|
+
# get the builds for the travis repo
|
101
|
+
repo = ENV['TRAVIS_REPO_SLUG']
|
102
|
+
travis = ::Travis::Repository.find(repo)
|
103
|
+
|
104
|
+
# search the builds for last succesful build
|
105
|
+
builds = travis.each_build.select do |build|
|
106
|
+
build.branch_info.eql?(self.branch) && build.passed?
|
107
|
+
end
|
108
|
+
@commit_sha = builds.first.commit.short_sha unless builds.empty?
|
109
|
+
end
|
110
|
+
end
|
111
|
+
end
|
112
|
+
end
|
@@ -0,0 +1,63 @@
|
|
1
|
+
require "spec_helper"
|
2
|
+
|
3
|
+
# [ ] test with incorrect authorization [error]
|
4
|
+
# [X] with non-existing course [error]
|
5
|
+
# [X] for non-existing assignment [error]
|
6
|
+
# [X] for existing assignment [pass]
|
7
|
+
|
8
|
+
describe Canvas::Workflow::Tags::AssignmentTag do
|
9
|
+
let(:doc) { doc_with_content(content) }
|
10
|
+
let(:prefix) { doc.site.config['canvas']['prefix'] }
|
11
|
+
let(:course) { doc.site.config['canvas']['course'] }
|
12
|
+
let(:content) { "{% assignment %}" }
|
13
|
+
let(:output) do
|
14
|
+
doc.data['title'] = "#{title}"
|
15
|
+
doc.content = content
|
16
|
+
doc.output = Jekyll::Renderer.new(doc.site, doc).run
|
17
|
+
end
|
18
|
+
|
19
|
+
before :each do
|
20
|
+
stub_request(:get, url).to_return(:status => status, :body => fixture(fix), :headers => { "Content-Type" => "application/json; charset=utf-8" })
|
21
|
+
end
|
22
|
+
let(:url) { "#{prefix}/api/v1/courses/#{course}/assignments?search_term=#{title}" }
|
23
|
+
|
24
|
+
context "with existing assignment" do
|
25
|
+
let(:status) { 200 }
|
26
|
+
let(:fix) { "assignment" }
|
27
|
+
let(:title) { "some assignment" }
|
28
|
+
|
29
|
+
it "produces the correct id" do
|
30
|
+
expect(output).to eq("<p>4</p>\n");
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
context "with non-existing component" do
|
35
|
+
context "course does not exist" do
|
36
|
+
let(:output) do
|
37
|
+
doc.data['title'] = "#{title}"
|
38
|
+
doc.site.config['canvas']['course'] = course
|
39
|
+
doc.content = content
|
40
|
+
doc.output = Jekyll::Renderer.new(doc.site, doc).run
|
41
|
+
end
|
42
|
+
|
43
|
+
let(:course) { "0123456789" }
|
44
|
+
let(:status) { 404 }
|
45
|
+
let(:fix) { "doesnotexist" }
|
46
|
+
let(:title) { "some assignment" }
|
47
|
+
|
48
|
+
it "raises an error" do
|
49
|
+
expect(-> { output }).to raise_error(Footrest::HttpError::NotFound)
|
50
|
+
end
|
51
|
+
end
|
52
|
+
|
53
|
+
context "assignment does not exist" do
|
54
|
+
let(:status) { 404 }
|
55
|
+
let(:fix) { "doesnotexist" }
|
56
|
+
let(:title) { "invalid assignment" }
|
57
|
+
|
58
|
+
it "raises an error" do
|
59
|
+
expect(-> { output }).to raise_error(Footrest::HttpError::NotFound)
|
60
|
+
end
|
61
|
+
end
|
62
|
+
end
|
63
|
+
end
|