canvas-workflow 0.5.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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,10 @@
1
+ require 'jekyll' # jekyll & liquid
2
+
3
+ module Canvas
4
+ module Workflow
5
+ module Tags
6
+ end
7
+ end
8
+ end
9
+
10
+ Dir[File.dirname(__FILE__) + '/tags/*.rb'].each { |file| require file }
@@ -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,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Canvas
4
+ module Workflow
5
+ VERSION = "0.5.0".freeze
6
+ end
7
+ 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