canvas-workflow 0.5.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/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
|