canvas-sak 1.0.0__tar.gz

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.
Files changed (30) hide show
  1. canvas_sak-1.0.0/LICENSE +24 -0
  2. canvas_sak-1.0.0/PKG-INFO +37 -0
  3. canvas_sak-1.0.0/README.md +10 -0
  4. canvas_sak-1.0.0/canvas_sak/canvas_sak.py +8 -0
  5. canvas_sak-1.0.0/canvas_sak/commands/__init__.py +17 -0
  6. canvas_sak-1.0.0/canvas_sak/commands/code_similarity.py +64 -0
  7. canvas_sak-1.0.0/canvas_sak/commands/collect_reference_info.py +52 -0
  8. canvas_sak-1.0.0/canvas_sak/commands/download_canvas_course.py +200 -0
  9. canvas_sak-1.0.0/canvas_sak/commands/download_submissions.py +68 -0
  10. canvas_sak-1.0.0/canvas_sak/commands/export_letter_grade.py +33 -0
  11. canvas_sak-1.0.0/canvas_sak/commands/grade_discussion.py +80 -0
  12. canvas_sak-1.0.0/canvas_sak/commands/help_me_setup.py +53 -0
  13. canvas_sak-1.0.0/canvas_sak/commands/list_courses.py +17 -0
  14. canvas_sak-1.0.0/canvas_sak/commands/list_students.py +21 -0
  15. canvas_sak-1.0.0/canvas_sak/commands/message_students.py +48 -0
  16. canvas_sak-1.0.0/canvas_sak/commands/min_grade_analyzer.py +97 -0
  17. canvas_sak-1.0.0/canvas_sak/commands/quiz.py +140 -0
  18. canvas_sak-1.0.0/canvas_sak/commands/set_fudge_points.py +47 -0
  19. canvas_sak-1.0.0/canvas_sak/commands/set_letter_grade.py +52 -0
  20. canvas_sak-1.0.0/canvas_sak/commands/upload_canvas_course.py +303 -0
  21. canvas_sak-1.0.0/canvas_sak/core.py +306 -0
  22. canvas_sak-1.0.0/canvas_sak/md2fhtml.py +26 -0
  23. canvas_sak-1.0.0/canvas_sak.egg-info/PKG-INFO +37 -0
  24. canvas_sak-1.0.0/canvas_sak.egg-info/SOURCES.txt +28 -0
  25. canvas_sak-1.0.0/canvas_sak.egg-info/dependency_links.txt +1 -0
  26. canvas_sak-1.0.0/canvas_sak.egg-info/entry_points.txt +2 -0
  27. canvas_sak-1.0.0/canvas_sak.egg-info/requires.txt +18 -0
  28. canvas_sak-1.0.0/canvas_sak.egg-info/top_level.txt +1 -0
  29. canvas_sak-1.0.0/pyproject.toml +33 -0
  30. canvas_sak-1.0.0/setup.cfg +4 -0
@@ -0,0 +1,24 @@
1
+ This is free and unencumbered software released into the public domain.
2
+
3
+ Anyone is free to copy, modify, publish, use, compile, sell, or
4
+ distribute this software, either in source code form or as a compiled
5
+ binary, for any purpose, commercial or non-commercial, and by any
6
+ means.
7
+
8
+ In jurisdictions that recognize copyright laws, the author or authors
9
+ of this software dedicate any and all copyright interest in the
10
+ software to the public domain. We make this dedication for the benefit
11
+ of the public at large and to the detriment of our heirs and
12
+ successors. We intend this dedication to be an overt act of
13
+ relinquishment in perpetuity of all present and future rights to this
14
+ software under copyright law.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
17
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
18
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
19
+ IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
20
+ OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
21
+ ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
22
+ OTHER DEALINGS IN THE SOFTWARE.
23
+
24
+ For more information, please refer to <https://unlicense.org>
@@ -0,0 +1,37 @@
1
+ Metadata-Version: 2.4
2
+ Name: canvas-sak
3
+ Version: 1.0.0
4
+ Summary: Command‑line Swiss Army Knife for Canvas LMS
5
+ Requires-Python: >=3.7
6
+ Description-Content-Type: text/markdown
7
+ License-File: LICENSE
8
+ Requires-Dist: arrow==1.2.3
9
+ Requires-Dist: beautifulsoup4==4.12.2
10
+ Requires-Dist: canvasapi
11
+ Requires-Dist: certifi==2023.7.22
12
+ Requires-Dist: charset-normalizer==3.2.0
13
+ Requires-Dist: click==8.1.3
14
+ Requires-Dist: colorama==0.4.6
15
+ Requires-Dist: idna==3.4
16
+ Requires-Dist: lxml==4.9.3
17
+ Requires-Dist: Markdown==3.4.4
18
+ Requires-Dist: markdownify==0.11.6
19
+ Requires-Dist: mosspy==1.0.9
20
+ Requires-Dist: python-dateutil==2.8.2
21
+ Requires-Dist: pytz==2023.3
22
+ Requires-Dist: requests==2.28.2
23
+ Requires-Dist: six==1.16.0
24
+ Requires-Dist: soupsieve==2.4.1
25
+ Requires-Dist: urllib3==1.26.16
26
+ Dynamic: license-file
27
+
28
+ # canvas_sak
29
+ a command-line python based tool for teachers who use canvas.
30
+
31
+ you can download from canvas_sak from pip
32
+
33
+ you will need to grab a "token" from your canvas account. go to the canvas webpage -> click on Account in the upper left -> click Settings -> scroll down and click the New Access Token button. you will need to put the token in a configuration file. `python3 canvas_sak.pyz help-me-setup` will tell you how and where to create that configuration file.
34
+
35
+ at this point the main thing this tool does is grade discussion assignments for participation: 1 point for posting and 1 point for replying.
36
+
37
+ it also will collect names and categories of assignments that past students excelled at for writing future letters of recommendation.
@@ -0,0 +1,10 @@
1
+ # canvas_sak
2
+ a command-line python based tool for teachers who use canvas.
3
+
4
+ you can download from canvas_sak from pip
5
+
6
+ you will need to grab a "token" from your canvas account. go to the canvas webpage -> click on Account in the upper left -> click Settings -> scroll down and click the New Access Token button. you will need to put the token in a configuration file. `python3 canvas_sak.pyz help-me-setup` will tell you how and where to create that configuration file.
7
+
8
+ at this point the main thing this tool does is grade discussion assignments for participation: 1 point for posting and 1 point for replying.
9
+
10
+ it also will collect names and categories of assignments that past students excelled at for writing future letters of recommendation.
@@ -0,0 +1,8 @@
1
+ import canvas_sak.core
2
+ from canvas_sak.commands import *
3
+
4
+ def main():
5
+ canvas_sak.core.canvas_sak()
6
+
7
+ if __name__ == "__main__":
8
+ main()
@@ -0,0 +1,17 @@
1
+ __all__ = [
2
+ "code_similarity",
3
+ "collect_reference_info",
4
+ "download_canvas_course",
5
+ "download_submissions",
6
+ "export_letter_grade",
7
+ "grade_discussion",
8
+ "help_me_setup",
9
+ "list_courses",
10
+ "list_students",
11
+ "message_students",
12
+ "min_grade_analyzer",
13
+ "set_fudge_points",
14
+ "set_letter_grade",
15
+ "upload_canvas_course",
16
+ "quiz",
17
+ ]
@@ -0,0 +1,64 @@
1
+ from canvas_sak.core import *
2
+ import glob
3
+
4
+ @canvas_sak.command()
5
+ @click.argument('course_name', metavar='course')
6
+ @click.argument('assignment_name', metavar='assignment', default='')
7
+ @click.argument('language', metavar='language')
8
+ @click.option('--dryrun/--no-dryrun', default=True, show_default=True, help="only show the grade, don't actually set it")
9
+ @click.option('--pause/--no-pause', default=False, show_default=True, help="pause before uploading")
10
+ @click.option('--multiple/--no-multiple', default=False, show_default=True, help="collect submissions from multiple classes")
11
+ def code_similarity(course_name, language, assignment_name, dryrun, pause, multiple):
12
+ '''
13
+ check submissions for code similarity using stanford MOSS.
14
+ '''
15
+ canvas = get_canvas_object()
16
+ courses = get_courses(canvas, course_name)
17
+ if len(courses) == 0:
18
+ error(f"no courses matched {course_name}")
19
+ exit(2)
20
+ if len(courses) > 1 and not multiple:
21
+ error(f"multiple matches for {course_name}")
22
+ for course in courses:
23
+ error(f" {course.name}")
24
+ exit(2)
25
+
26
+ parser = ConfigParser()
27
+ parser.read([config_ini])
28
+ moss_userid = parser['MOSS']['userid']
29
+
30
+ moss = mosspy.Moss(moss_userid, language)
31
+ with tempfile.TemporaryDirectory("canvas_sak.attach") as tempdir:
32
+ for course in courses:
33
+ assignment = get_assignment(course, assignment_name)
34
+ usermap = {u.id: u.name for u in course.get_users()}
35
+ submissions = list(assignment.get_submissions())
36
+ with click.progressbar(length=len(submissions), label="downloading", item_show_func=lambda x: x) as bar:
37
+ for sub in assignment.get_submissions():
38
+ if sub.user_id not in usermap:
39
+ continue
40
+ udir = f"{tempdir}/{usermap[sub.user_id]}"
41
+ os.makedirs(udir)
42
+ bar.update(1, usermap[sub.user_id])
43
+ for attachment in sub.attachments:
44
+ aname = f"{udir}/{attachment.filename}"
45
+ with open(aname, "wb") as f:
46
+ f.write(requests.get(attachment.url).content)
47
+ if aname.endswith(".zip"):
48
+ with zipfile.ZipFile(aname, "r") as zf:
49
+ zf.extractall(udir)
50
+ files_to_upload = [x for x in glob.glob(f"{tempdir}/**/*.{language}") if '/__MACOSX/' not in x]
51
+ info(f"uploading {files_to_upload}")
52
+
53
+ if pause:
54
+ input(f"pausing. code is in {tempdir}. hit enter to continue")
55
+ if dryrun:
56
+ info(f"would upload {len(files_to_upload)} files to MOSS")
57
+ else:
58
+ for file in files_to_upload:
59
+ moss.addFile(file)
60
+ count = len(moss.files)
61
+ with click.progressbar(length=count, label="uploading", item_show_func=lambda x: x) as bar:
62
+ moss_url = moss.send(on_send=lambda fp, dn: bar.update(1, dn))
63
+ info(f"results at {moss_url}")
64
+ info(f"download with: wget -k -e robots=off -np -r {moss_url}")
@@ -0,0 +1,52 @@
1
+ from canvas_sak.core import *
2
+
3
+ @canvas_sak.command()
4
+ @click.argument("course")
5
+ @click.option('-t', 'thresholds', metavar='threshold', multiple=True, default=[84, 90, 95], show_default=True,
6
+ type=click.INT, help="""
7
+ assignment groups with grades about the lowest threshold will have a +, the next
8
+ lowest gets ++, and so on. assignment groups below the lowest threshold will not
9
+ be printed.
10
+ """)
11
+ @click.option('-s', 'skip', metavar='skip_assignment', multiple=True, default=['iclickr', 'ungraded', 'imported'],
12
+ show_default=True, help="""
13
+ assignment groups with the listed keywords will not be collected.
14
+ """)
15
+ def collect_reference_info(course, thresholds, skip):
16
+ '''collect high level information about students of previous classes to help writing reference letters'''
17
+ Grade = namedtuple('Grade', ['category', 'grade'])
18
+ canvas = get_canvas_object()
19
+ for course in get_courses(canvas, course, is_active=False, is_finished=True):
20
+ results = canvas.graphql('query { course(id: "' + str(course.id) + '''") {
21
+ assignmentGroupsConnection {
22
+ nodes { name
23
+ gradesConnection {
24
+ edges { node { currentScore
25
+ enrollment { user { name } }
26
+ } } } } } } }''')
27
+ grades_by_student = defaultdict(list)
28
+
29
+ for assignment_group in results['data']['course']['assignmentGroupsConnection']['nodes']:
30
+ category = assignment_group['name']
31
+ if any(x in category.lower() for x in skip):
32
+ continue
33
+ for grade in assignment_group['gradesConnection']['edges']:
34
+ score = grade['node']['currentScore']
35
+ name = grade['node']['enrollment']['user']['name']
36
+ if score:
37
+ pluses = to_plus(score, thresholds)
38
+ if pluses:
39
+ grades_by_student[name].append(Grade(category, pluses))
40
+ for i in grades_by_student.items():
41
+ label = f'{i[0]}@{format_course_name(course.name)}'
42
+ output(f'{label} {" ".join([g.category + ":" + g.grade for g in i[1]])}')
43
+
44
+ def to_plus(grade, levels):
45
+ """ convert a score to a list of pluses based on grade """
46
+ pluses = ""
47
+ for level in levels:
48
+ if grade >= level:
49
+ pluses += '+'
50
+ return pluses
51
+
52
+
@@ -0,0 +1,200 @@
1
+ from canvas_sak.commands.upload_canvas_course import page_name_to_url
2
+ from canvas_sak.core import *
3
+ from canvas_sak.md2fhtml import *
4
+
5
+
6
+ def download_modules(course, target, dryrun):
7
+ def base_inner_module_to_str(module_item):
8
+ return f'{" " * (module_item.indent + 1)}* {sanitize(module_item.title)}; {module_item.type}{"" if module_item.published else "; published=False"}'
9
+
10
+ def named_inner_module_to_str(module_item):
11
+ module_item_target_name = None
12
+ if hasattr(module_item, 'content_id'):
13
+ module_item_target_name = rr4id[module_item.type + str(module_item.content_id)].name
14
+ else:
15
+ module_item_target_name = rr4url[module_item.page_url].name
16
+
17
+ if module_item_target_name == module_item.title:
18
+ module_item_target_name = None
19
+
20
+ return f'{base_inner_module_to_str(module_item)}{f"; target={module_item_target_name}" if module_item_target_name else ""}'
21
+
22
+ def external_tool(mi):
23
+ return f'{base_inner_module_to_str(mi)}; newtab={"True" if mi.new_tab else "False"}; url={mi.external_url}'
24
+
25
+ module_renderers = {
26
+ "Assignment": named_inner_module_to_str,
27
+ "File": named_inner_module_to_str,
28
+ "Page": named_inner_module_to_str,
29
+ "Quiz": named_inner_module_to_str,
30
+ "Discussion": named_inner_module_to_str,
31
+ "SubHeader": base_inner_module_to_str,
32
+ "ExternalUrl": external_tool,
33
+ "ExternalTool": external_tool,
34
+ }
35
+
36
+ top_modules = []
37
+ id2name = {}
38
+ output = ''
39
+ for module in course.get_modules():
40
+ id2name[module.id] = module.name
41
+ ms = f"# {module.name}"
42
+ if module.unlock_at:
43
+ ms += f"; unlock={module.unlock_at}"
44
+ if module.require_sequential_progress:
45
+ ms += f"; sequential"
46
+ if module.prerequisite_module_ids:
47
+ ms += f"; prereqs={','.join([id2name[id] for id in module.prerequisite_module_ids])}"
48
+ if hasattr(module, "completed_at") and module.completed_at:
49
+ ms += f"; completed={module.completed_at}"
50
+ if not module.published:
51
+ ms += f"; published=False"
52
+ output += ms + '\n'
53
+ for item in module.get_module_items():
54
+ if item.type in module_renderers:
55
+ output += module_renderers[item.type](item) + '\n'
56
+ else:
57
+ warn(f"cannot render {item.__dict__}")
58
+
59
+ if dryrun:
60
+ info(f"would have written:\n{output}to {target}")
61
+ else:
62
+ with open(target, "w") as fd:
63
+ fd.write(output)
64
+
65
+
66
+ def download_discussions(course, target, dryrun):
67
+ os.makedirs(target, exist_ok=True)
68
+ for discussion in course.get_discussion_topics():
69
+ # windows can't have : in the filename :'(
70
+ target_file = os.path.join(target, discussion.title.strip().replace("\\", "-").replace(":", ";") + ".md")
71
+ if os.path.exists(target_file):
72
+ info(f"{target_file} already exists for {discussion.title}")
73
+ else:
74
+ if dryrun:
75
+ info(f"would download {target_file} for {discussion.title}")
76
+ else:
77
+ info(f"downloading {target_file} for {discussion.title}")
78
+ with open(target_file, "w+") as fd:
79
+ fd.write(f"# {discussion.title}\n")
80
+ # todo: we need to fix up the links based on the rr maps
81
+ fd.write(html2mdstr(discussion.message))
82
+
83
+
84
+ def download_assignments(course, target, dryrun):
85
+ pass
86
+
87
+
88
+ def fix_links(text):
89
+ # that funky ( [^\)]*) shouldn't be needed, but i do see trailing names after the URL
90
+ return re.sub(r"\(https://\w+.instructure.com/courses/\w+/pages/([^ )]+)( [^\)]*)\)", r"(\1)", text)
91
+
92
+
93
+ def download_pages(course, target, dryrun):
94
+ os.makedirs(target, exist_ok=True)
95
+ for page in course.get_pages(include=["body"]):
96
+ url = page_name_to_url(page.title)
97
+ if page.url != url:
98
+ warn(f"calculated page url for {page.title} ({url}) does not equal {page.url}")
99
+ if dryrun:
100
+ info(f"would download {page.title} to {url}")
101
+ else:
102
+ with open(os.path.join(target, url) + ".md", "w+") as fd:
103
+ fd.write(f"published: {page.published}\n")
104
+ if page.publish_at:
105
+ fd.write(f"publish_at: {page.publish_at}\n")
106
+ if page.front_page:
107
+ fd.write(f"front_page: {page.front_page}\n")
108
+ fd.write(f"title: {page.title}\n")
109
+ fd.write(fix_links(html2mdstr(page.body)))
110
+
111
+
112
+ def download_files(course, target, dryrun):
113
+ class ToDownload(NamedTuple):
114
+ file: canvasapi.file.File
115
+ target: str
116
+
117
+ error_seen = False
118
+ to_download = []
119
+ for folder in course.get_folders():
120
+ target_dir = os.path.join(target, str(folder))
121
+ if os.path.exists(target_dir):
122
+ if not os.path.isdir(target_dir):
123
+ error(f"{target_dir} is not a directory. skipping")
124
+ error_seen = True
125
+ continue
126
+ else:
127
+ if dryrun:
128
+ info(f"would create {target_dir}")
129
+ else:
130
+ os.makedirs(target_dir)
131
+
132
+ for file in folder.get_files():
133
+ full_name = os.path.join(str(folder), str(file))
134
+ target_file = os.path.join(target_dir, str(file))
135
+ if dryrun:
136
+ info(f"would download {full_name} to {target_file}")
137
+ else:
138
+ if os.path.exists(target_file):
139
+ warn(f"{target_file} already exists. skipping")
140
+ else:
141
+ to_download.append(ToDownload(file, target_file))
142
+
143
+ if to_download:
144
+ with click.progressbar(to_download, label="downloading",
145
+ item_show_func=lambda i: str(i.file) if i else "") as tds:
146
+ for td in tds:
147
+ td.file.download(td.target)
148
+ if error_seen:
149
+ exit(2)
150
+
151
+
152
+ def download_announcements(course, target, dryrun):
153
+ pass
154
+
155
+
156
+ @canvas_sak.command()
157
+ @click.argument('course_name', metavar='course')
158
+ @click.option('--dryrun/--no-dryrun', default=True, show_default=True, help="show what would happen, but don't do it.")
159
+ @click.option('--modules/--no-modules', default=False, show_default=True,
160
+ help=f"download modules to the {click.style('modules', underline=True, italic=True)} file.")
161
+ @click.option('--discussions/--no-discussions', default=False, show_default=True,
162
+ help=f"download discussions to the {click.style('discussions', underline=True, italic=True)} subdirectory.")
163
+ @click.option('--assignments', default=False, show_default=True,
164
+ help=f"download assignments to the {click.style('assignments', underline=True, italic=True)} subdirectory.")
165
+ @click.option('--pages/--no-pages', default=False, show_default=True,
166
+ help=f"download pages to the {click.style('pages', underline=True, italic=True)} subdirectory.")
167
+ @click.option('--files', default=False, show_default=True,
168
+ help=f"download files to the {click.style('files', underline=True, italic=True)} subdirectory.")
169
+ @click.option('--announcements', default=False, show_default=True,
170
+ help=f"download announcements to the {click.style('announcements', underline=True, italic=True)} subdirectory.")
171
+ @click.option('--all/--no-all', default=False, show_default=True,
172
+ help="download all content to corresponding directories")
173
+ @click.option("--target", default='.', show_default=True, help="download content parent directory.")
174
+ def download_course_content(course_name, dryrun, modules, discussions, assignments, pages, files, announcements, all,
175
+ target):
176
+ """download course content from local files"""
177
+ canvas = get_canvas_object()
178
+ course = get_course(canvas, course_name, is_active=False)
179
+ output(f"found {course.name}")
180
+ map_course_resource_records(course)
181
+
182
+ if all:
183
+ modules = discussions = assignments = pages = files = announcements = True
184
+
185
+ if not (modules or discussions or assignments or pages or files or announcements):
186
+ error("nothing selected to download")
187
+ exit(1)
188
+
189
+ if modules:
190
+ download_modules(course, os.path.join(target, 'modules'), dryrun)
191
+ if discussions:
192
+ download_discussions(course, os.path.join(target, 'discussions'), dryrun)
193
+ if assignments:
194
+ download_assignments(course, os.path.join(target, 'assignments'), dryrun)
195
+ if pages:
196
+ download_pages(course, os.path.join(target, 'pages'), dryrun)
197
+ if files:
198
+ download_files(course, os.path.join(target, 'files'), dryrun)
199
+ if announcements:
200
+ download_announcements(course, os.path.join(target, 'announcements'), dryrun)
@@ -0,0 +1,68 @@
1
+ from canvas_sak.core import *
2
+
3
+ @canvas_sak.command()
4
+ @click.argument('course_name', metavar='course')
5
+ @click.argument('assignment_name', metavar='assignment', default='')
6
+ @click.option('--dryrun/--no-dryrun', default=True, show_default=True,
7
+ help="only show the grade, don't actually set it")
8
+ def download_submissions(course_name, assignment_name, dryrun):
9
+ '''
10
+ download submissions for an assignment.
11
+ '''
12
+
13
+ canvas = get_canvas_object()
14
+
15
+ course = get_course(canvas, course_name)
16
+
17
+ assignment = get_assignment(course, assignment_name)
18
+
19
+ query = """
20
+ query submissions($assignmentid: ID!) {
21
+ assignment(id: $assignmentid) { submissionsConnection {
22
+ nodes { attachments { url displayName } user { name }
23
+ commentsConnection { nodes { comment attachments { url displayName}}}
24
+ }
25
+ }}
26
+ }
27
+ """
28
+ result = canvas.graphql(query, {"assignmentid": assignment.id})
29
+ submissions = result['data']['assignment']['submissionsConnection']['nodes']
30
+
31
+ if dryrun:
32
+ info(f"{len(submissions)} submissions to download")
33
+ sys.exit(0)
34
+
35
+ with click.progressbar(length=len(submissions), label="downloading submission", show_pos=True) as bar:
36
+ for s in submissions:
37
+ count = 1
38
+ name = s['user']['name']
39
+ dir = os.path.join(assignment_name, name.replace(' ', '-'))
40
+ os.makedirs(dir, exist_ok=True)
41
+ for a in s['attachments']:
42
+ download_attachment(f'{dir}/submission{count}', a)
43
+ count += 1
44
+ count = 1
45
+ for c in s['commentsConnection']['nodes']:
46
+ with open(os.path.join(dir, f"comment{count}.txt"), 'w') as fd:
47
+ fd.write(c['comment'])
48
+ subcount = 1
49
+ for ca in c['attachments']:
50
+ download_attachment(f'{dir}/comment{count}attachment{subcount}', ca)
51
+ subcount += 1
52
+ count += 1
53
+ bar.update(1)
54
+
55
+
56
+
57
+ def download_attachment(basename, a):
58
+ fname = a['displayName']
59
+ suffix = os.path.splitext(fname)[1]
60
+ durl = a['url']
61
+ info(f'downloading {a}')
62
+ with requests.get(durl) as response:
63
+ if response.status_code != 200:
64
+ error(f'error {response.status_code} fetching {durl}')
65
+ return
66
+ with open(f"{basename}{suffix}", "wb") as fd:
67
+ for chunk in response.iter_content():
68
+ fd.write(chunk)
@@ -0,0 +1,33 @@
1
+ from canvas_sak.core import *
2
+
3
+ @canvas_sak.command()
4
+ @click.argument("course")
5
+ @click.argument("csv_output_file", type=click.File("w"))
6
+ def export_letter_grade(course, csv_output_file):
7
+ ''' export course letter grade to CSV
8
+
9
+ the "Reported Letter Grade" column must be setup in the gradebook.
10
+ this command will pull down the letter grades from that column an print a CSV record
11
+ with the student id and the corresponding letter grade.
12
+ output will got to the indicated csv_output_file.
13
+ an output file name of - will go to stdout.
14
+ '''
15
+
16
+ canvas = get_canvas_object()
17
+ course = get_course(canvas, course)
18
+
19
+ rlg_assignment = get_assignment(course, "Reported Letter Grade")
20
+ if not rlg_assignment:
21
+ error('the "Reported Letter Grade" assignment hasn\'t been set up')
22
+ exit(2)
23
+
24
+ count = 0
25
+ csv_output_file.write("Student ID,Grade\n")
26
+ for submission in rlg_assignment.get_submissions():
27
+ user = course.get_user(submission.user_id)
28
+ if user.sis_user_id:
29
+ csv_output_file.write(f"{user.sis_user_id}, {submission.grade}\n")
30
+ count += 1
31
+
32
+ info(f"{count} records written to {csv_output_file.name}")
33
+
@@ -0,0 +1,80 @@
1
+ from canvas_sak.core import *
2
+
3
+ @canvas_sak.command()
4
+ @click.argument('course_name', metavar='course')
5
+ @click.argument('assignment_name', metavar='assignment', default='')
6
+ @click.option('--dryrun/--no-dryrun', default=True, show_default=True,
7
+ help="only show the grade, don't actually set it")
8
+ @click.option('--min-words', default=5, show_default=True, help="the minimum number of valid words to get credit")
9
+ @click.option('--points-comment', default=1, show_default=True, help="number of points for posting a comment")
10
+ @click.option('--max-points', default=2, show_default=True, help="maximum number of points to give")
11
+ def grade_discussion(course_name, assignment_name, dryrun, min_words, points_comment, max_points):
12
+ '''
13
+ grade a discussion assignment based on participation.
14
+
15
+ one point is added for a post and another for a reply for a total of 2.
16
+ this tool assumes that the student must post first to reply.
17
+
18
+ course_name - any part of an active course name. for example, 249 will match CS249.
19
+ the course must active (it has not passed the end date) to be eligible for matching.
20
+ only the first match will be used.
21
+
22
+ assignment_name - any part of an assigment's name will be matched. only the first
23
+ match will be used.
24
+ '''
25
+
26
+ canvas = get_canvas_object()
27
+
28
+ course = get_course(canvas, course_name)
29
+
30
+ now = datetime.datetime.now(datetime.timezone.utc)
31
+ assignments = get_assignments(course, assignment_name)
32
+ for assignment_data in assignments:
33
+ info(f"grading {assignment_data['title']}")
34
+ assignment = course.get_assignment(assignment_data['assignment_id'])
35
+ due_at_date = assignment.due_at_date
36
+ if due_at_date > now:
37
+ warn(f"{assignment_data['title']} not due: skipping")
38
+ continue
39
+ submissions = assignment.get_submissions()
40
+ grades = {}
41
+ skipped = 0
42
+ processed = 0
43
+
44
+ # we calculate the grades and upload them in two steps since the second
45
+ # step is slow so we want to do a progress bar. we could still do it all
46
+ # in one pass if we could get the count. i can't find a way to do that
47
+ # without going through the submissions
48
+ for s in submissions:
49
+ processed += 1
50
+ if not hasattr(s, "discussion_entries"):
51
+ skipped += 1
52
+ continue
53
+
54
+ grade = 0
55
+ for entry in s.discussion_entries:
56
+ entry = DiscussionEntry(None, entry)
57
+ if entry.created_at_date > due_at_date:
58
+ info(
59
+ f"skipping discussion from {s.user_id} submitted at {entry.created_at_date} but due {due_at_date}")
60
+ continue
61
+ if count_words(entry.message) > 5:
62
+ grade = min(grade + points_comment, max_points)
63
+
64
+ grades[s] = grade
65
+
66
+ if skipped == processed:
67
+ error(f"'{assignment.name}' doesn't appear to be a discussion assignment")
68
+ sys.exit(2)
69
+
70
+ info(f"processed {processed}, skipped {skipped}.")
71
+
72
+ if dryrun:
73
+ info("would have posted:")
74
+ for i in grades.items():
75
+ info(f" {i[0]} {i[1]}")
76
+ else:
77
+ with click.progressbar(length=len(grades), label="updating grades", show_pos=True) as bar:
78
+ for i in grades.items():
79
+ i[0].edit(submission={'posted_grade': i[1]})
80
+ bar.update(1)
@@ -0,0 +1,53 @@
1
+ from canvas_sak.core import *
2
+
3
+ @canvas_sak.command()
4
+ def help_me_setup():
5
+ """provide guidance through the setup process"""
6
+ if os.path.isfile(config_ini):
7
+ info(f"great! {config_ini} exists. let's check it!")
8
+ else:
9
+ error(f"""{config_ini} does not exist. you need to create it.
10
+ it should have the form:""")
11
+ print_config_ini_format(True)
12
+ sys.exit(2)
13
+
14
+ parser = ConfigParser()
15
+ try:
16
+ parser.read([config_ini])
17
+ except:
18
+ error(f"there was a problem reading {config_ini}. make sure it has the format of:")
19
+ print_config_ini_format(False)
20
+
21
+ check_key("SERVER", parser)
22
+ url = check_key("url", parser["SERVER"])
23
+ p = urllib.parse.urlparse(url)
24
+ if p.scheme != "https":
25
+ error(f"url in {config_ini} must start with https://")
26
+ sys.exit(2)
27
+
28
+ if p.path:
29
+ error(f"url in {config_ini} must have the form http://hostname with no other /")
30
+ sys.exit(2)
31
+
32
+ try:
33
+ with urllib.request.urlopen(url) as con:
34
+ info(f"{url} is reachable.")
35
+ except Exception as e:
36
+ error(f"got '{e}' accessing {url}. please check the url in {config_ini}.")
37
+ sys.exit(2)
38
+
39
+ token = check_key("token", parser["SERVER"])
40
+ if token and len(token) > 20:
41
+ info(f"token found. checking to see if it is usable")
42
+ else:
43
+ error(f"token is too short. make sure you have copied it correctly from canvas.")
44
+ sys.exit(2)
45
+
46
+ try:
47
+ canvas = Canvas(url, token)
48
+ info(f"you are successfully able to use canvas as {canvas.get_current_user().name}")
49
+ except Exception as e:
50
+ error(f"problem accessing canvas: {e}")
51
+ sys.exit(2)
52
+
53
+ sys.exit(0)