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.
@@ -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