kattis2canvas 0.1.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.
- kattis2canvas-0.1.0/PKG-INFO +106 -0
- kattis2canvas-0.1.0/README.md +79 -0
- kattis2canvas-0.1.0/pyproject.toml +44 -0
- kattis2canvas-0.1.0/setup.cfg +4 -0
- kattis2canvas-0.1.0/src/kattis2canvas/__init__.py +3 -0
- kattis2canvas-0.1.0/src/kattis2canvas/__main__.py +6 -0
- kattis2canvas-0.1.0/src/kattis2canvas/cli.py +591 -0
- kattis2canvas-0.1.0/src/kattis2canvas.egg-info/PKG-INFO +106 -0
- kattis2canvas-0.1.0/src/kattis2canvas.egg-info/SOURCES.txt +11 -0
- kattis2canvas-0.1.0/src/kattis2canvas.egg-info/dependency_links.txt +1 -0
- kattis2canvas-0.1.0/src/kattis2canvas.egg-info/entry_points.txt +2 -0
- kattis2canvas-0.1.0/src/kattis2canvas.egg-info/requires.txt +4 -0
- kattis2canvas-0.1.0/src/kattis2canvas.egg-info/top_level.txt +1 -0
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: kattis2canvas
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: CLI tool to integrate Kattis offerings with Canvas LMS courses
|
|
5
|
+
Author: bcr33d
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/bcr33d/kattis2canvas
|
|
8
|
+
Project-URL: Repository, https://github.com/bcr33d/kattis2canvas
|
|
9
|
+
Keywords: kattis,canvas,lms,education,grading
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Environment :: Console
|
|
12
|
+
Classifier: Intended Audience :: Education
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Topic :: Education
|
|
21
|
+
Requires-Python: >=3.8
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
Requires-Dist: click>=8.0
|
|
24
|
+
Requires-Dist: requests>=2.25
|
|
25
|
+
Requires-Dist: beautifulsoup4>=4.9
|
|
26
|
+
Requires-Dist: canvasapi>=2.0
|
|
27
|
+
|
|
28
|
+
# kattis2canvas
|
|
29
|
+
|
|
30
|
+
this is a simple python tool that uses the canvasapi toolkit to integrate a kattis offering with a canvas course. the tool was specifically made to work with the commercial kattis (as you will see in a moment). it would take a bit of tweaking on the web scraping to make it work with open.kattis.com.
|
|
31
|
+
|
|
32
|
+
the kattis connection is done using web scraping and thus it is very fragile! at the end, i will highlight where it is most vulnerable.
|
|
33
|
+
|
|
34
|
+
# setting up kattis2canvas
|
|
35
|
+
|
|
36
|
+
## the config file
|
|
37
|
+
|
|
38
|
+
first you will need to set up the config file. it has all your authorization tokens so DON'T CHECK IT IN. i specificially look for it in the app_dir as defined by click to get it far away from source. on linux this ends up being a file called ~/.config/kattis2canvas.ini. this is how it should be populated:
|
|
39
|
+
|
|
40
|
+
```
|
|
41
|
+
[kattis]
|
|
42
|
+
username: YOUR_KATTIS_USERNAME_NOT_EMAIL
|
|
43
|
+
token: SOME_RANDOM_CHARACTERS
|
|
44
|
+
hostname: THE_DOMAIN_NAME_OF_YOUR_KATTIS_INSTANCE
|
|
45
|
+
loginurl: THE_URL_TO_LOG_IN_TO_KATTIS
|
|
46
|
+
|
|
47
|
+
[canvas]
|
|
48
|
+
url=URL_OF_YOUR_CANVAS_INSTANCE
|
|
49
|
+
token=SOME_RANDOM_CHARACTERS
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
you can easily get the kattis section by going to https://\<kattis>/download/kattisrc where \<kattis> is your instance of kattis. you will need to move the lines around slightly. for canvas the url is the one you use to access the main page of canvas. you generate the token in the bottom of the Account -> Settings page.
|
|
53
|
+
|
|
54
|
+
you can check that everything is set up by running:
|
|
55
|
+
|
|
56
|
+
```
|
|
57
|
+
kattis2canvas list-offerings
|
|
58
|
+
kattis2canvas list-assignments
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
or if you built the pyz file using make_zipapp.sh
|
|
62
|
+
|
|
63
|
+
```
|
|
64
|
+
kattis2canvas list-offerings
|
|
65
|
+
kattis2canvas list-assignments
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## mapping student kattis accounts to canvas accounts
|
|
69
|
+
|
|
70
|
+
in canvas, you can associate various URLs with your account in the Links section of Account -> Profile. students need to put the URL of their kattis account in a link with the word "kattis" (in any case) in the title. this ends up being the join key for kattis2canvas.
|
|
71
|
+
|
|
72
|
+
you can check if the students have set up the links properly using
|
|
73
|
+
|
|
74
|
+
```
|
|
75
|
+
kattis2canvas kattislinks
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
# using kattis2canvas
|
|
79
|
+
|
|
80
|
+
## populating kattis assignments in canvas
|
|
81
|
+
|
|
82
|
+
when you create new kattis assignments, you will need to get it mirrored into canvas. currently the **course2canvas** command will put all of the assignments into an assignment group called kattis. the assignment group must be created in your course before **course2canvas**.
|
|
83
|
+
|
|
84
|
+
when you specify the names of offerings in kattis and courses in canvas, you can specify a substring of the name and **kattis2canvas** will be able to use it as long as it matches exactly one name. if it doesn't, it will show it what it found.
|
|
85
|
+
|
|
86
|
+
when creating the assignment in canvas, anything you have put in the description in kattis will also be replicated in canvas along with a link to the kattis assignment.
|
|
87
|
+
|
|
88
|
+
if you have made changes to a kattis assignment that you have already populated in canvas, use the --force option to force an update. (right now there isn't a way to force individual assignments.)
|
|
89
|
+
|
|
90
|
+
if you use modules, you can use the **--add-to-module** flag to add the kattis assignments to a module. at this point, it puts all of the kattis assignments into that one module.
|
|
91
|
+
|
|
92
|
+
## getting the results to canvas
|
|
93
|
+
|
|
94
|
+
the **submissions2canvas** will replicate results from student submissions to kattis into canvas. it will only replicate results that are either better or the same as or newer than previous results it has replicated. a summary of the problem, score, and link to the submission will be added as a comment for the student in the gradebook for the relevant assignment. the idea is that when it's time to grade, you have easy access to the results and the link to the source from canvas speedgrader.
|
|
95
|
+
|
|
96
|
+
# kattis webscraping
|
|
97
|
+
|
|
98
|
+
unfortuately, scraping the kattis API is very adhoc and it would be naive to think that it wouldn't change in ways that will break this tool. we use BeautifulSoupe (it's pretty beautiful...) so here are the features that the script relies on for kattis webpages: (note the term "assume" is used for things we have to believe because that is the only reasonable way that we can use the information we are given.)
|
|
99
|
+
|
|
100
|
+
* the list of offerings: (HOSTNAME is from the config file above) we assume http://HOSTNAME/ will give us a page will all the offerings and the urls in the href of those offerings will have the form **/courses/[^/]+/[^/]+**
|
|
101
|
+
* the list of assignments: we assume the offering page will have the detail page for assignments in hrefs of anchor tabs of the form "assignments/\w+$".
|
|
102
|
+
* the assignment details: we assume the assignment detail page will have an \<h2> tag with the text "Description" followed by a sibling \<p> tag that entirely contains the description. we also assume that there will be a \<td> for "start time" and another for "end time" we do case insensitve comparisons to find them. we assume that the following \<td> tage will have the time.
|
|
103
|
+
* time: TIME IS HARD! if the time is recent, kattis will drop the date, so if we get a time with no date, we take the current time and up date the HH:MM:SS with the date we get from kattis. we also assume all dates are UTC.
|
|
104
|
+
* getting submissions: we assume the submissions for an assignment are found at https://HOSTNAME/OFFERING/assignments/ASSIGNMENT_ID/submissions it appears that all submissions for the assignment are listed on that page. submissions for a problem outside of the assignment time period will not show up on that page. the submissions are in a table called "judege"table". the headers \<th> that we are looking for are "User" for the user url, "Problem" for the name of the problem, "Test cases" for the score reflected as sucess/count with -/- indicating no tries, and "" indicating the header for the url of the submission. once we know the column numbers we want, we have to look for the \<tbody> child of the table (problems happen if you try to look for \<td> recursively from the table!) then we look for \<tr> children of \<tbody> which have a "data-submission-id" attribute.
|
|
105
|
+
|
|
106
|
+
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# kattis2canvas
|
|
2
|
+
|
|
3
|
+
this is a simple python tool that uses the canvasapi toolkit to integrate a kattis offering with a canvas course. the tool was specifically made to work with the commercial kattis (as you will see in a moment). it would take a bit of tweaking on the web scraping to make it work with open.kattis.com.
|
|
4
|
+
|
|
5
|
+
the kattis connection is done using web scraping and thus it is very fragile! at the end, i will highlight where it is most vulnerable.
|
|
6
|
+
|
|
7
|
+
# setting up kattis2canvas
|
|
8
|
+
|
|
9
|
+
## the config file
|
|
10
|
+
|
|
11
|
+
first you will need to set up the config file. it has all your authorization tokens so DON'T CHECK IT IN. i specificially look for it in the app_dir as defined by click to get it far away from source. on linux this ends up being a file called ~/.config/kattis2canvas.ini. this is how it should be populated:
|
|
12
|
+
|
|
13
|
+
```
|
|
14
|
+
[kattis]
|
|
15
|
+
username: YOUR_KATTIS_USERNAME_NOT_EMAIL
|
|
16
|
+
token: SOME_RANDOM_CHARACTERS
|
|
17
|
+
hostname: THE_DOMAIN_NAME_OF_YOUR_KATTIS_INSTANCE
|
|
18
|
+
loginurl: THE_URL_TO_LOG_IN_TO_KATTIS
|
|
19
|
+
|
|
20
|
+
[canvas]
|
|
21
|
+
url=URL_OF_YOUR_CANVAS_INSTANCE
|
|
22
|
+
token=SOME_RANDOM_CHARACTERS
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
you can easily get the kattis section by going to https://\<kattis>/download/kattisrc where \<kattis> is your instance of kattis. you will need to move the lines around slightly. for canvas the url is the one you use to access the main page of canvas. you generate the token in the bottom of the Account -> Settings page.
|
|
26
|
+
|
|
27
|
+
you can check that everything is set up by running:
|
|
28
|
+
|
|
29
|
+
```
|
|
30
|
+
kattis2canvas list-offerings
|
|
31
|
+
kattis2canvas list-assignments
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
or if you built the pyz file using make_zipapp.sh
|
|
35
|
+
|
|
36
|
+
```
|
|
37
|
+
kattis2canvas list-offerings
|
|
38
|
+
kattis2canvas list-assignments
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## mapping student kattis accounts to canvas accounts
|
|
42
|
+
|
|
43
|
+
in canvas, you can associate various URLs with your account in the Links section of Account -> Profile. students need to put the URL of their kattis account in a link with the word "kattis" (in any case) in the title. this ends up being the join key for kattis2canvas.
|
|
44
|
+
|
|
45
|
+
you can check if the students have set up the links properly using
|
|
46
|
+
|
|
47
|
+
```
|
|
48
|
+
kattis2canvas kattislinks
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
# using kattis2canvas
|
|
52
|
+
|
|
53
|
+
## populating kattis assignments in canvas
|
|
54
|
+
|
|
55
|
+
when you create new kattis assignments, you will need to get it mirrored into canvas. currently the **course2canvas** command will put all of the assignments into an assignment group called kattis. the assignment group must be created in your course before **course2canvas**.
|
|
56
|
+
|
|
57
|
+
when you specify the names of offerings in kattis and courses in canvas, you can specify a substring of the name and **kattis2canvas** will be able to use it as long as it matches exactly one name. if it doesn't, it will show it what it found.
|
|
58
|
+
|
|
59
|
+
when creating the assignment in canvas, anything you have put in the description in kattis will also be replicated in canvas along with a link to the kattis assignment.
|
|
60
|
+
|
|
61
|
+
if you have made changes to a kattis assignment that you have already populated in canvas, use the --force option to force an update. (right now there isn't a way to force individual assignments.)
|
|
62
|
+
|
|
63
|
+
if you use modules, you can use the **--add-to-module** flag to add the kattis assignments to a module. at this point, it puts all of the kattis assignments into that one module.
|
|
64
|
+
|
|
65
|
+
## getting the results to canvas
|
|
66
|
+
|
|
67
|
+
the **submissions2canvas** will replicate results from student submissions to kattis into canvas. it will only replicate results that are either better or the same as or newer than previous results it has replicated. a summary of the problem, score, and link to the submission will be added as a comment for the student in the gradebook for the relevant assignment. the idea is that when it's time to grade, you have easy access to the results and the link to the source from canvas speedgrader.
|
|
68
|
+
|
|
69
|
+
# kattis webscraping
|
|
70
|
+
|
|
71
|
+
unfortuately, scraping the kattis API is very adhoc and it would be naive to think that it wouldn't change in ways that will break this tool. we use BeautifulSoupe (it's pretty beautiful...) so here are the features that the script relies on for kattis webpages: (note the term "assume" is used for things we have to believe because that is the only reasonable way that we can use the information we are given.)
|
|
72
|
+
|
|
73
|
+
* the list of offerings: (HOSTNAME is from the config file above) we assume http://HOSTNAME/ will give us a page will all the offerings and the urls in the href of those offerings will have the form **/courses/[^/]+/[^/]+**
|
|
74
|
+
* the list of assignments: we assume the offering page will have the detail page for assignments in hrefs of anchor tabs of the form "assignments/\w+$".
|
|
75
|
+
* the assignment details: we assume the assignment detail page will have an \<h2> tag with the text "Description" followed by a sibling \<p> tag that entirely contains the description. we also assume that there will be a \<td> for "start time" and another for "end time" we do case insensitve comparisons to find them. we assume that the following \<td> tage will have the time.
|
|
76
|
+
* time: TIME IS HARD! if the time is recent, kattis will drop the date, so if we get a time with no date, we take the current time and up date the HH:MM:SS with the date we get from kattis. we also assume all dates are UTC.
|
|
77
|
+
* getting submissions: we assume the submissions for an assignment are found at https://HOSTNAME/OFFERING/assignments/ASSIGNMENT_ID/submissions it appears that all submissions for the assignment are listed on that page. submissions for a problem outside of the assignment time period will not show up on that page. the submissions are in a table called "judege"table". the headers \<th> that we are looking for are "User" for the user url, "Problem" for the name of the problem, "Test cases" for the score reflected as sucess/count with -/- indicating no tries, and "" indicating the header for the url of the submission. once we know the column numbers we want, we have to look for the \<tbody> child of the table (problems happen if you try to look for \<td> recursively from the table!) then we look for \<tr> children of \<tbody> which have a "data-submission-id" attribute.
|
|
78
|
+
|
|
79
|
+
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61.0", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "kattis2canvas"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "CLI tool to integrate Kattis offerings with Canvas LMS courses"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
requires-python = ">=3.8"
|
|
12
|
+
authors = [
|
|
13
|
+
{name = "bcr33d"}
|
|
14
|
+
]
|
|
15
|
+
keywords = ["kattis", "canvas", "lms", "education", "grading"]
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Development Status :: 4 - Beta",
|
|
18
|
+
"Environment :: Console",
|
|
19
|
+
"Intended Audience :: Education",
|
|
20
|
+
"Operating System :: OS Independent",
|
|
21
|
+
"Programming Language :: Python :: 3",
|
|
22
|
+
"Programming Language :: Python :: 3.8",
|
|
23
|
+
"Programming Language :: Python :: 3.9",
|
|
24
|
+
"Programming Language :: Python :: 3.10",
|
|
25
|
+
"Programming Language :: Python :: 3.11",
|
|
26
|
+
"Programming Language :: Python :: 3.12",
|
|
27
|
+
"Topic :: Education",
|
|
28
|
+
]
|
|
29
|
+
dependencies = [
|
|
30
|
+
"click>=8.0",
|
|
31
|
+
"requests>=2.25",
|
|
32
|
+
"beautifulsoup4>=4.9",
|
|
33
|
+
"canvasapi>=2.0",
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
[project.urls]
|
|
37
|
+
Homepage = "https://github.com/bcr33d/kattis2canvas"
|
|
38
|
+
Repository = "https://github.com/bcr33d/kattis2canvas"
|
|
39
|
+
|
|
40
|
+
[project.scripts]
|
|
41
|
+
kattis2canvas = "kattis2canvas.cli:top"
|
|
42
|
+
|
|
43
|
+
[tool.setuptools.packages.find]
|
|
44
|
+
where = ["src"]
|
|
@@ -0,0 +1,591 @@
|
|
|
1
|
+
import collections
|
|
2
|
+
import concurrent.futures
|
|
3
|
+
import configparser
|
|
4
|
+
import datetime
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import sys
|
|
8
|
+
from fractions import Fraction
|
|
9
|
+
from typing import NamedTuple, Optional
|
|
10
|
+
|
|
11
|
+
import click
|
|
12
|
+
import pprint
|
|
13
|
+
import requests
|
|
14
|
+
import requests.cookies
|
|
15
|
+
import requests.exceptions
|
|
16
|
+
from bs4 import BeautifulSoup
|
|
17
|
+
from canvasapi import Canvas, module
|
|
18
|
+
from canvasapi.course import Course
|
|
19
|
+
from canvasapi.user import User
|
|
20
|
+
|
|
21
|
+
HEADERS = {'User-Agent': 'kattis-to-canvas'}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class Config(NamedTuple):
|
|
25
|
+
kattis_username: str
|
|
26
|
+
kattis_token: str
|
|
27
|
+
kattis_loginurl: str
|
|
28
|
+
kattis_hostname: str
|
|
29
|
+
canvas_url: str
|
|
30
|
+
canvas_token: str
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
config: Optional[Config] = None
|
|
34
|
+
login_cookies: Optional[requests.cookies.RequestsCookieJar] = None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class Student(NamedTuple):
|
|
38
|
+
kattis_url: str
|
|
39
|
+
name: str
|
|
40
|
+
email: str
|
|
41
|
+
canvas_id: str
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class Submission(NamedTuple):
|
|
45
|
+
user: str
|
|
46
|
+
problem: str
|
|
47
|
+
score: float
|
|
48
|
+
url: str
|
|
49
|
+
date: datetime.datetime
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
now = datetime.datetime.now(datetime.timezone.utc)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def error(message: str):
|
|
56
|
+
click.echo(click.style(message, fg='red'))
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def info(message: str):
|
|
60
|
+
click.echo(click.style(message, fg='blue'))
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def warn(message: str):
|
|
64
|
+
click.echo(click.style(message, fg='yellow'))
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def check_status(rsp: requests.Response):
|
|
68
|
+
if rsp.status_code != 200:
|
|
69
|
+
error(f"got status {rsp.status_code} for {rsp.url}.")
|
|
70
|
+
exit(6)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
# return the last element of a URL
|
|
74
|
+
def extract_last(pathish: str) -> str:
|
|
75
|
+
last_slash = pathish.rindex("/")
|
|
76
|
+
if last_slash:
|
|
77
|
+
pathish = pathish[last_slash + 1:]
|
|
78
|
+
return pathish
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
# for debugging
|
|
82
|
+
def introspect(o):
|
|
83
|
+
print("class", o.__class__)
|
|
84
|
+
for i in dir(o):
|
|
85
|
+
print(i)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def web_get(url: str) -> requests.Response:
|
|
89
|
+
rsp: requests.Response = requests.get(url, cookies=login_cookies, headers=HEADERS)
|
|
90
|
+
check_status(rsp)
|
|
91
|
+
return rsp
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@click.group()
|
|
95
|
+
def top():
|
|
96
|
+
config_ini = click.get_app_dir("kattis2canvas.ini")
|
|
97
|
+
parser = configparser.ConfigParser()
|
|
98
|
+
parser.read([config_ini])
|
|
99
|
+
global config
|
|
100
|
+
try:
|
|
101
|
+
config = Config(
|
|
102
|
+
kattis_username=parser['kattis']['username'],
|
|
103
|
+
kattis_token=parser['kattis']['token'],
|
|
104
|
+
kattis_hostname=parser['kattis']['hostname'],
|
|
105
|
+
kattis_loginurl=parser['kattis']['loginurl'],
|
|
106
|
+
canvas_url=parser['canvas']['url'],
|
|
107
|
+
canvas_token=parser['canvas']['token'],
|
|
108
|
+
)
|
|
109
|
+
except:
|
|
110
|
+
print(f"""problem getting configuration from {config_ini}. should have the following lines:
|
|
111
|
+
|
|
112
|
+
[kattis]
|
|
113
|
+
username=kattis_username
|
|
114
|
+
token=kattis_token
|
|
115
|
+
hostname: something_like_sjsu.kattis.com
|
|
116
|
+
loginurl: https://something_like_sjsu.kattis.com
|
|
117
|
+
[canvas]
|
|
118
|
+
url=https://something_like_sjsu.instructure.com
|
|
119
|
+
token=convas_token
|
|
120
|
+
""")
|
|
121
|
+
exit(2)
|
|
122
|
+
|
|
123
|
+
global login_cookies
|
|
124
|
+
args = {'user': config.kattis_username, 'script': 'true', 'token': config.kattis_token}
|
|
125
|
+
rsp = requests.post(config.kattis_loginurl, data=args, headers=HEADERS)
|
|
126
|
+
if rsp.status_code != 200:
|
|
127
|
+
error(f"Kattis login failed. Status: {rsp.status_code}")
|
|
128
|
+
exit(2)
|
|
129
|
+
login_cookies = rsp.cookies
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def get_offerings(offering_pattern: str) -> str:
|
|
133
|
+
rsp = web_get(f"https://{config.kattis_hostname}/")
|
|
134
|
+
bs = BeautifulSoup(rsp.content, 'html.parser')
|
|
135
|
+
for a in bs.find_all('a'):
|
|
136
|
+
h = a.get('href')
|
|
137
|
+
if h and re.match("/courses/[^/]+/[^/]+", h) and offering_pattern in h:
|
|
138
|
+
yield h
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
@top.command()
|
|
142
|
+
@click.argument("name", default="")
|
|
143
|
+
def list_offerings(name: str):
|
|
144
|
+
"""
|
|
145
|
+
list the possible offerings.
|
|
146
|
+
:param name: a substring of the offering name
|
|
147
|
+
"""
|
|
148
|
+
|
|
149
|
+
for offering in get_offerings(name):
|
|
150
|
+
info(str(offering))
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
# reformat kattis date format to canvas format
|
|
154
|
+
def extract_kattis_date(element: str) -> str:
|
|
155
|
+
if element == "infinity":
|
|
156
|
+
element = "2100-01-01 00:00 UTC"
|
|
157
|
+
return datetime.datetime.strftime(datetime.datetime.strptime(element, "%Y-%m-%d %H:%M %Z"), "%Y-%m-%dT%H:%M:00%z")
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
# convert canvas UTC to datetime
|
|
161
|
+
def extract_canvas_date(element: str) -> datetime.datetime:
|
|
162
|
+
return datetime.datetime.strptime(element, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=datetime.timezone.utc)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
class Assignment(NamedTuple):
|
|
166
|
+
url: str
|
|
167
|
+
assignment_id: str
|
|
168
|
+
title: str
|
|
169
|
+
description: str
|
|
170
|
+
start: str
|
|
171
|
+
end: str
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def get_assignments(offering: str) -> [Assignment]:
|
|
175
|
+
rsp = web_get(f"https://{config.kattis_hostname}{offering}")
|
|
176
|
+
bs = BeautifulSoup(rsp.content, 'html.parser')
|
|
177
|
+
for a in bs.find_all('a'):
|
|
178
|
+
h = a.get('href')
|
|
179
|
+
if h and re.search(r"assignments/\w+$", h):
|
|
180
|
+
url = f"https://{config.kattis_hostname}{h}"
|
|
181
|
+
rsp2 = web_get(url)
|
|
182
|
+
bs2 = BeautifulSoup(rsp2.content, 'html.parser')
|
|
183
|
+
description_h2 = bs2.find("h2", string="Description", recursive=True)
|
|
184
|
+
description = None
|
|
185
|
+
if description_h2:
|
|
186
|
+
p = description_h2.find_next_sibling("p")
|
|
187
|
+
if p:
|
|
188
|
+
description = p.text
|
|
189
|
+
all_td = iter(bs2.find_all("td"))
|
|
190
|
+
start = None
|
|
191
|
+
end = None
|
|
192
|
+
for td in all_td:
|
|
193
|
+
if td.get_text(strip=True).casefold() == "start time".casefold():
|
|
194
|
+
start = extract_kattis_date(next(all_td).get_text(strip=True))
|
|
195
|
+
if td.get_text(strip=True).casefold() == "end time".casefold():
|
|
196
|
+
end = extract_kattis_date(next(all_td).get_text(strip=True))
|
|
197
|
+
yield (Assignment(
|
|
198
|
+
url=url, assignment_id=url[url.rindex('/') + 1:], title=a.getText(),
|
|
199
|
+
description=description, start=start, end=end
|
|
200
|
+
))
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
@top.command()
|
|
204
|
+
@click.argument("offering", default="")
|
|
205
|
+
def list_assignments(offering):
|
|
206
|
+
"""
|
|
207
|
+
list the assignments for the given offering.
|
|
208
|
+
:param offering: a substring of the offering name
|
|
209
|
+
"""
|
|
210
|
+
for offering in get_offerings(offering):
|
|
211
|
+
for assignment in get_assignments(offering):
|
|
212
|
+
info(
|
|
213
|
+
f"{assignment.title}: {assignment.start} to {assignment.end} {assignment.description} {assignment.url}")
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
@top.command()
|
|
217
|
+
@click.argument("offering")
|
|
218
|
+
@click.argument("assignment")
|
|
219
|
+
def download_submissions(offering, assignment):
|
|
220
|
+
"""
|
|
221
|
+
download the submissions for an assignment in an offering. offerings and assignments that have the given substring
|
|
222
|
+
will match.
|
|
223
|
+
"""
|
|
224
|
+
for o in get_offerings(offering):
|
|
225
|
+
for a in get_assignments(o):
|
|
226
|
+
if assignment in a.title:
|
|
227
|
+
for student, probs in get_best_submissions(o, a.assignment_id).items():
|
|
228
|
+
for problem, submission in probs.items():
|
|
229
|
+
base_path = f"{offering}/{assignment}/{problem}/{student}"
|
|
230
|
+
os.makedirs(base_path, exist_ok=True)
|
|
231
|
+
rsp, name = download_submission(submission.url)
|
|
232
|
+
with open(base_path + "/" + name, "wb") as f:
|
|
233
|
+
f.write(rsp.content)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def download_submission(url):
|
|
237
|
+
rsp = web_get(f"https://{config.kattis_hostname}{url}")
|
|
238
|
+
bs = BeautifulSoup(rsp.content, 'html.parser')
|
|
239
|
+
src_div = bs.find(class_="file_source-content-file", recursive=True)
|
|
240
|
+
a = src_div.find("a", recursive=True)
|
|
241
|
+
h3 = src_div.find("h3")
|
|
242
|
+
name = os.path.basename(h3.get_text().strip())
|
|
243
|
+
sanitize(name)
|
|
244
|
+
return web_get(f"https://{config.kattis_hostname}{a.get('href')}"), name
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def sanitize(name):
|
|
248
|
+
return re.sub(r"[^\w.]", "_", name)
|
|
249
|
+
|
|
250
|
+
def get_course(canvas, name, is_active=True) -> Course:
|
|
251
|
+
""" find one course based on partial match """
|
|
252
|
+
course_list = get_courses(canvas, name, is_active)
|
|
253
|
+
if len(course_list) == 0:
|
|
254
|
+
error(f'no courses found that contain {name}. options are:')
|
|
255
|
+
for c in get_courses(canvas, "", is_active):
|
|
256
|
+
error(fr" {c.name}")
|
|
257
|
+
sys.exit(2)
|
|
258
|
+
elif len(course_list) > 1:
|
|
259
|
+
error(f"multiple courses found for {name}:")
|
|
260
|
+
for c in course_list:
|
|
261
|
+
error(f" {c.name}")
|
|
262
|
+
sys.exit(2)
|
|
263
|
+
return course_list[0]
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def get_courses(canvas: Canvas, name: str, is_active=True, is_finished=False) -> [Course]:
|
|
267
|
+
""" find the courses based on partial match """
|
|
268
|
+
courses = canvas.get_courses(enrollment_type="teacher")
|
|
269
|
+
course_list = []
|
|
270
|
+
for c in courses:
|
|
271
|
+
start = c.start_at_date if hasattr(c, "start_at_date") else now
|
|
272
|
+
end = c.end_at_date if hasattr(c, "end_at_date") else now
|
|
273
|
+
if is_active and (start > now or end < now):
|
|
274
|
+
continue
|
|
275
|
+
if is_finished and end >= now:
|
|
276
|
+
continue
|
|
277
|
+
if name in c.name:
|
|
278
|
+
c.start = start
|
|
279
|
+
c.end = end
|
|
280
|
+
course_list.append(c)
|
|
281
|
+
return course_list
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
@top.command()
|
|
285
|
+
@click.argument("offering")
|
|
286
|
+
@click.argument("canvas_course")
|
|
287
|
+
@click.option("--dryrun/--no-dryrun", default=True, help="show planned actions, do not make them happen.")
|
|
288
|
+
@click.option("--force/--no-force", default=False, help="force an update of an assignment if it already exists.")
|
|
289
|
+
@click.option("--add-to-module", help="the module to add the assignment to.")
|
|
290
|
+
def course2canvas(offering, canvas_course, dryrun, force, add_to_module):
|
|
291
|
+
"""
|
|
292
|
+
create assignments in canvas for all the assignments in kattis.
|
|
293
|
+
"""
|
|
294
|
+
offerings = list(get_offerings(offering))
|
|
295
|
+
if len(offerings) == 0:
|
|
296
|
+
error(f"no offerings found for {offering}")
|
|
297
|
+
exit(3)
|
|
298
|
+
elif len(offerings) > 1:
|
|
299
|
+
error(f"multiple offerings found for {offering}: {', '.join(offerings)}")
|
|
300
|
+
exit(3)
|
|
301
|
+
|
|
302
|
+
canvas = Canvas(config.canvas_url, config.canvas_token)
|
|
303
|
+
course = get_course(canvas, canvas_course)
|
|
304
|
+
|
|
305
|
+
kattis_group = None
|
|
306
|
+
for ag in course.get_assignment_groups():
|
|
307
|
+
if ag.name == 'kattis':
|
|
308
|
+
kattis_group = ag
|
|
309
|
+
break
|
|
310
|
+
if not kattis_group:
|
|
311
|
+
# create assignment group if not present on canvas
|
|
312
|
+
if dryrun:
|
|
313
|
+
info(f"would create assignment group {kattis_group}.")
|
|
314
|
+
else:
|
|
315
|
+
kattis_group = course.create_assignment_group(name='kattis')
|
|
316
|
+
info(f"created assignment group {kattis_group}.")
|
|
317
|
+
|
|
318
|
+
if add_to_module:
|
|
319
|
+
modules = {m.name: m for m in course.get_modules()}
|
|
320
|
+
if add_to_module in modules:
|
|
321
|
+
add_to_module = modules[add_to_module]
|
|
322
|
+
else:
|
|
323
|
+
if dryrun:
|
|
324
|
+
info(f"would create and publish {add_to_module}.")
|
|
325
|
+
else:
|
|
326
|
+
args = {"name": add_to_module}
|
|
327
|
+
add_to_module = course.create_module(module=args)
|
|
328
|
+
info(f"created module {add_to_module}.")
|
|
329
|
+
|
|
330
|
+
args = {'published': "true"}
|
|
331
|
+
add_to_module.edit(module=args)
|
|
332
|
+
info(f"published module {add_to_module}.")
|
|
333
|
+
|
|
334
|
+
canvas_assignments = {a.name: a for a in course.get_assignments(assignment_group_id=kattis_group.id)}
|
|
335
|
+
|
|
336
|
+
# make sure assignments are in place
|
|
337
|
+
sorted_assignments = list(get_assignments(offerings[0]))
|
|
338
|
+
sorted_assignments.sort(key=lambda a: a.start)
|
|
339
|
+
for assignment in sorted_assignments:
|
|
340
|
+
description = assignment.description if assignment.description else ""
|
|
341
|
+
if assignment.title in canvas_assignments:
|
|
342
|
+
info(f"{assignment.title} already exists.")
|
|
343
|
+
if force:
|
|
344
|
+
if dryrun:
|
|
345
|
+
info(f"would update {assignment.title}.")
|
|
346
|
+
else:
|
|
347
|
+
canvas_assignments[assignment.title].edit(assignment={
|
|
348
|
+
'assignment_group_id': kattis_group.id,
|
|
349
|
+
'name': assignment.title,
|
|
350
|
+
'description': f'Solve the problems found at <a href="{assignment.url}">{assignment.url}</a>. {description}',
|
|
351
|
+
'points_possible': 100,
|
|
352
|
+
'due_at': assignment.end,
|
|
353
|
+
'lock_at': assignment.end,
|
|
354
|
+
'unlock_at': assignment.start,
|
|
355
|
+
'published': True,
|
|
356
|
+
})
|
|
357
|
+
info(f"updated {assignment.title}.")
|
|
358
|
+
else:
|
|
359
|
+
if dryrun:
|
|
360
|
+
info(f"would create {assignment}")
|
|
361
|
+
elif 'late' in assignment.title and assignment.title.replace("-late", "") in canvas_assignments:
|
|
362
|
+
info(f"no new assignment created as --late assignment for {assignment.title.replace('-late', '')}.")
|
|
363
|
+
continue
|
|
364
|
+
else:
|
|
365
|
+
canvas_assignments[assignment.title] = course.create_assignment({
|
|
366
|
+
'assignment_group_id': kattis_group.id,
|
|
367
|
+
'name': assignment.title,
|
|
368
|
+
'description': f'Solve the problems found at <a href="{assignment.url}">{assignment.url}</a>. {description}',
|
|
369
|
+
'points_possible': 100,
|
|
370
|
+
'due_at': assignment.end,
|
|
371
|
+
'lock_at': assignment.end,
|
|
372
|
+
'unlock_at': assignment.start,
|
|
373
|
+
'published': True,
|
|
374
|
+
})
|
|
375
|
+
info(f"created {assignment.title}.")
|
|
376
|
+
if add_to_module:
|
|
377
|
+
if assignment.title not in [i.title for i in add_to_module.get_module_items()]:
|
|
378
|
+
add_to_module.create_module_item(module_item={
|
|
379
|
+
'title': assignment.title,
|
|
380
|
+
'type': 'Assignment',
|
|
381
|
+
'content_id': canvas_assignments[assignment.title].id,
|
|
382
|
+
})
|
|
383
|
+
info(f'{assignment.title} added to {add_to_module.name}')
|
|
384
|
+
else:
|
|
385
|
+
info(f'{assignment.title} already in {add_to_module.name}')
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def is_student_enrollment(user: User):
|
|
389
|
+
return "StudentEnrollment" in [e['type'] for e in user.enrollments]
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def find_kattis_link(profile: dict) -> str:
|
|
393
|
+
kattis_url = None
|
|
394
|
+
for link in profile["links"]:
|
|
395
|
+
if "kattis" in link["title"].lower():
|
|
396
|
+
kattis_url = link["url"]
|
|
397
|
+
return kattis_url
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
class KattisLink(NamedTuple):
|
|
401
|
+
canvas_user: User
|
|
402
|
+
kattis_user: str
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def get_kattis_links(course: Course) -> [KattisLink]:
|
|
406
|
+
# this is so terribly slow because of all the requests, we need threads
|
|
407
|
+
with concurrent.futures.ThreadPoolExecutor(max_workers=20) as executor:
|
|
408
|
+
futures = []
|
|
409
|
+
for u in course.get_users(include=["enrollments"]):
|
|
410
|
+
if "StudentEnrollment" not in [e['type'] for e in u.enrollments]:
|
|
411
|
+
continue
|
|
412
|
+
|
|
413
|
+
def get_profile(user: User) -> Optional[KattisLink]:
|
|
414
|
+
profile = user.get_profile(include=["links"])
|
|
415
|
+
kattis_url = find_kattis_link(profile)
|
|
416
|
+
kattis_url = extract_last(kattis_url) if kattis_url else None
|
|
417
|
+
return KattisLink(canvas_user=user, kattis_user=kattis_url)
|
|
418
|
+
|
|
419
|
+
futures.append(executor.submit(get_profile, u))
|
|
420
|
+
|
|
421
|
+
links = [f.result() for f in futures if not None]
|
|
422
|
+
links.sort(key=lambda l: l.canvas_user.name)
|
|
423
|
+
return links
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
@top.command()
|
|
427
|
+
@click.argument("canvas_course")
|
|
428
|
+
def kattislinks(canvas_course):
|
|
429
|
+
"""
|
|
430
|
+
list the students in the class with their email and kattis links.
|
|
431
|
+
"""
|
|
432
|
+
canvas = Canvas(config.canvas_url, config.canvas_token)
|
|
433
|
+
course = get_course(canvas, canvas_course)
|
|
434
|
+
|
|
435
|
+
for link in get_kattis_links(course):
|
|
436
|
+
if not is_student_enrollment(link.canvas_user):
|
|
437
|
+
continue
|
|
438
|
+
if link.kattis_user:
|
|
439
|
+
info(f"{link.canvas_user.name}\t{link.canvas_user.email}\t{link.kattis_user}")
|
|
440
|
+
else:
|
|
441
|
+
error(f"{link.canvas_user.name}\t{link.canvas_user.email} missing kattis link")
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
@top.command()
|
|
445
|
+
@click.argument("offering")
|
|
446
|
+
@click.argument("canvas_course")
|
|
447
|
+
@click.option("--dryrun/--no-dryrun", default=True, help="show planned actions, do not make them happen.")
|
|
448
|
+
def submissions2canvas(offering, canvas_course, dryrun):
|
|
449
|
+
"""
|
|
450
|
+
mirror summary of submission from kattis into canvas as a submission comment.
|
|
451
|
+
"""
|
|
452
|
+
offerings = list(get_offerings(offering))
|
|
453
|
+
if len(offerings) == 0:
|
|
454
|
+
error(f"no offerings found for {offering}")
|
|
455
|
+
exit(3)
|
|
456
|
+
elif len(offerings) > 1:
|
|
457
|
+
error(f"multiple offerings found for {offering}: {', '.join(offerings)}")
|
|
458
|
+
exit(3)
|
|
459
|
+
|
|
460
|
+
canvas = Canvas(config.canvas_url, config.canvas_token)
|
|
461
|
+
course = get_course(canvas, canvas_course)
|
|
462
|
+
|
|
463
|
+
kattis_user2canvas_id = {}
|
|
464
|
+
canvas_id2kattis_user = {}
|
|
465
|
+
for link in get_kattis_links(course):
|
|
466
|
+
if link.kattis_user:
|
|
467
|
+
kattis_user2canvas_id[link.kattis_user] = link.canvas_user
|
|
468
|
+
canvas_id2kattis_user[link.canvas_user.id] = link.kattis_user
|
|
469
|
+
else:
|
|
470
|
+
warn(f"kattis link missing for {link.canvas_user.name} {link.canvas_user.email}.")
|
|
471
|
+
|
|
472
|
+
kattis_group = None
|
|
473
|
+
for ag in course.get_assignment_groups():
|
|
474
|
+
if ag.name == 'kattis':
|
|
475
|
+
kattis_group = ag
|
|
476
|
+
break
|
|
477
|
+
|
|
478
|
+
if not kattis_group:
|
|
479
|
+
error(f"no kattis assignment group in {canvas_course}")
|
|
480
|
+
exit(4)
|
|
481
|
+
|
|
482
|
+
assignments = {a.name: a for a in course.get_assignments(assignment_group_id=kattis_group.id)}
|
|
483
|
+
|
|
484
|
+
for assignment in get_assignments(offerings[0]):
|
|
485
|
+
if assignment.title.replace("-late", "") not in assignments:
|
|
486
|
+
error(f"{assignment.title.replace('-late', '')} not in canvas {canvas_course}")
|
|
487
|
+
else:
|
|
488
|
+
prefix = "LATE: " if "late" in assignment.title else ""
|
|
489
|
+
best_submissions = get_best_submissions(offering=offerings[0],
|
|
490
|
+
assignment_id=assignment.assignment_id)
|
|
491
|
+
canvas_assignment = assignments[assignment.title.replace("-late", "")]
|
|
492
|
+
# find the last submissions and only add a submission if the best submission is after latest
|
|
493
|
+
submissions_by_user = {}
|
|
494
|
+
for canvas_submission in canvas_assignment.get_submissions(include=["submission_comments"]):
|
|
495
|
+
if canvas_submission.user_id in canvas_id2kattis_user:
|
|
496
|
+
if canvas_submission.user_id in submissions_by_user:
|
|
497
|
+
warn(
|
|
498
|
+
f'duplicate submission for {kattis_user2canvas_id[canvas_submission.user_id]} in {assignment.title}')
|
|
499
|
+
submissions_by_user[canvas_id2kattis_user[canvas_submission.user_id]] = canvas_submission
|
|
500
|
+
last_comment = datetime.datetime.fromordinal(1).replace(tzinfo=datetime.timezone.utc)
|
|
501
|
+
if canvas_submission.submission_comments:
|
|
502
|
+
for comment in canvas_submission.submission_comments:
|
|
503
|
+
created_at = extract_canvas_date(comment['created_at'])
|
|
504
|
+
if created_at > last_comment:
|
|
505
|
+
last_comment = created_at
|
|
506
|
+
canvas_submission.last_comment = last_comment
|
|
507
|
+
|
|
508
|
+
for user, best in best_submissions.items():
|
|
509
|
+
for kattis_submission in best.values():
|
|
510
|
+
if user not in submissions_by_user:
|
|
511
|
+
warn(f"i don't see a canvas submission for {user}")
|
|
512
|
+
elif user not in kattis_user2canvas_id:
|
|
513
|
+
warn(f'skipping submission for unknown user {user}')
|
|
514
|
+
elif kattis_submission.date > submissions_by_user[user].last_comment:
|
|
515
|
+
if dryrun:
|
|
516
|
+
warn(
|
|
517
|
+
f"would update {kattis_user2canvas_id[kattis_submission.user]} on problem {kattis_submission.problem} scored {kattis_submission.score}")
|
|
518
|
+
else:
|
|
519
|
+
submissions_by_user[user].edit(comment={
|
|
520
|
+
'text_comment': f"{prefix}Submission https://{config.kattis_hostname}{kattis_submission.url} scored {kattis_submission.score} on {kattis_submission.problem}."})
|
|
521
|
+
info(
|
|
522
|
+
f"updated {submissions_by_user[user]} {kattis_user2canvas_id[kattis_submission.user]} for {assignment.title}")
|
|
523
|
+
else:
|
|
524
|
+
info(f"{user} up to date")
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
def get_best_submissions(offering: str, assignment_id: str) -> {str: {str: Submission}}:
|
|
528
|
+
best_submissions = collections.defaultdict(dict)
|
|
529
|
+
rsp = web_get(f"https://{config.kattis_hostname}{offering}/assignments/{assignment_id}/submissions")
|
|
530
|
+
bs = BeautifulSoup(rsp.content, "html.parser")
|
|
531
|
+
judge_table = bs.find("table", id="judge_table")
|
|
532
|
+
headers = [x.get_text().strip() for x in judge_table.find_all("th")]
|
|
533
|
+
tbody = judge_table.find("tbody")
|
|
534
|
+
for submissions in tbody.find_all("tr", recursive=False):
|
|
535
|
+
if not submissions.get("data-submission-id"):
|
|
536
|
+
continue
|
|
537
|
+
submissions = submissions.find_all("td", recursive=False)
|
|
538
|
+
if not submissions:
|
|
539
|
+
continue
|
|
540
|
+
props = {}
|
|
541
|
+
for index, td in enumerate(submissions):
|
|
542
|
+
a = td.find("a")
|
|
543
|
+
props[headers[index]] = a.get("href") if a else td.get_text().strip()
|
|
544
|
+
date = props["Date"]
|
|
545
|
+
if "-" in date:
|
|
546
|
+
date = datetime.datetime.strptime(date, "%Y-%m-%d %H:%M:%S").replace(tzinfo=now.tzinfo)
|
|
547
|
+
else:
|
|
548
|
+
hms = datetime.datetime.strptime(date, "%H:%M:%S")
|
|
549
|
+
date = now.replace(hour=hms.hour, minute=hms.minute, second=hms.second)
|
|
550
|
+
# it's not clear when the short date version is used. it might be used when it is less than 24 hours,
|
|
551
|
+
# in which case, just setting the time will make the date 24 hours more than it should be
|
|
552
|
+
if date > now:
|
|
553
|
+
date -= datetime.timedelta(days=1)
|
|
554
|
+
|
|
555
|
+
score = 0.0 if props["Test cases"] == "-/-" else float(Fraction(props["Test cases"])) * 100
|
|
556
|
+
submission = Submission(user=extract_last(props["User"]), problem=extract_last(props["Problem"]), date=date,
|
|
557
|
+
score=score, url=props[""])
|
|
558
|
+
if submission.problem not in best_submissions[submission.user]:
|
|
559
|
+
best_submissions[submission.user] = {submission.problem: submission}
|
|
560
|
+
else:
|
|
561
|
+
current_best = best_submissions[submission.user][submission.problem]
|
|
562
|
+
if current_best.score < submission.score or (
|
|
563
|
+
current_best.score == submission.score and current_best.date < submission.date):
|
|
564
|
+
best_submissions[submission.user][submission.problem] = submission
|
|
565
|
+
return best_submissions
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
@top.command()
|
|
569
|
+
@click.argument("canvas_course")
|
|
570
|
+
def sendemail(canvas_course):
|
|
571
|
+
"""
|
|
572
|
+
Email students if they don't have a kattis link in their profile.
|
|
573
|
+
It takes one input argument canvas course name.
|
|
574
|
+
"""
|
|
575
|
+
canvas = Canvas(config.canvas_url, config.canvas_token)
|
|
576
|
+
course = get_course(canvas, canvas_course)
|
|
577
|
+
|
|
578
|
+
for link in get_kattis_links(course):
|
|
579
|
+
if not is_student_enrollment(link.canvas_user):
|
|
580
|
+
continue
|
|
581
|
+
if not link.kattis_user:
|
|
582
|
+
canvas.create_conversation(recipients=link.canvas_user.id,
|
|
583
|
+
body="Hello " + link.canvas_user.name + "\n\n\n Please add the missing kattis "
|
|
584
|
+
"link in bio for "
|
|
585
|
+
"course " + canvas_course + ".",
|
|
586
|
+
subject='Reminder: Add kattis link in profile')
|
|
587
|
+
info(f"Able to send conversation to : {link.canvas_user.id}")
|
|
588
|
+
|
|
589
|
+
|
|
590
|
+
if __name__ == "__main__":
|
|
591
|
+
top()
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: kattis2canvas
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: CLI tool to integrate Kattis offerings with Canvas LMS courses
|
|
5
|
+
Author: bcr33d
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/bcr33d/kattis2canvas
|
|
8
|
+
Project-URL: Repository, https://github.com/bcr33d/kattis2canvas
|
|
9
|
+
Keywords: kattis,canvas,lms,education,grading
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Environment :: Console
|
|
12
|
+
Classifier: Intended Audience :: Education
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Topic :: Education
|
|
21
|
+
Requires-Python: >=3.8
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
Requires-Dist: click>=8.0
|
|
24
|
+
Requires-Dist: requests>=2.25
|
|
25
|
+
Requires-Dist: beautifulsoup4>=4.9
|
|
26
|
+
Requires-Dist: canvasapi>=2.0
|
|
27
|
+
|
|
28
|
+
# kattis2canvas
|
|
29
|
+
|
|
30
|
+
this is a simple python tool that uses the canvasapi toolkit to integrate a kattis offering with a canvas course. the tool was specifically made to work with the commercial kattis (as you will see in a moment). it would take a bit of tweaking on the web scraping to make it work with open.kattis.com.
|
|
31
|
+
|
|
32
|
+
the kattis connection is done using web scraping and thus it is very fragile! at the end, i will highlight where it is most vulnerable.
|
|
33
|
+
|
|
34
|
+
# setting up kattis2canvas
|
|
35
|
+
|
|
36
|
+
## the config file
|
|
37
|
+
|
|
38
|
+
first you will need to set up the config file. it has all your authorization tokens so DON'T CHECK IT IN. i specificially look for it in the app_dir as defined by click to get it far away from source. on linux this ends up being a file called ~/.config/kattis2canvas.ini. this is how it should be populated:
|
|
39
|
+
|
|
40
|
+
```
|
|
41
|
+
[kattis]
|
|
42
|
+
username: YOUR_KATTIS_USERNAME_NOT_EMAIL
|
|
43
|
+
token: SOME_RANDOM_CHARACTERS
|
|
44
|
+
hostname: THE_DOMAIN_NAME_OF_YOUR_KATTIS_INSTANCE
|
|
45
|
+
loginurl: THE_URL_TO_LOG_IN_TO_KATTIS
|
|
46
|
+
|
|
47
|
+
[canvas]
|
|
48
|
+
url=URL_OF_YOUR_CANVAS_INSTANCE
|
|
49
|
+
token=SOME_RANDOM_CHARACTERS
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
you can easily get the kattis section by going to https://\<kattis>/download/kattisrc where \<kattis> is your instance of kattis. you will need to move the lines around slightly. for canvas the url is the one you use to access the main page of canvas. you generate the token in the bottom of the Account -> Settings page.
|
|
53
|
+
|
|
54
|
+
you can check that everything is set up by running:
|
|
55
|
+
|
|
56
|
+
```
|
|
57
|
+
kattis2canvas list-offerings
|
|
58
|
+
kattis2canvas list-assignments
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
or if you built the pyz file using make_zipapp.sh
|
|
62
|
+
|
|
63
|
+
```
|
|
64
|
+
kattis2canvas list-offerings
|
|
65
|
+
kattis2canvas list-assignments
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## mapping student kattis accounts to canvas accounts
|
|
69
|
+
|
|
70
|
+
in canvas, you can associate various URLs with your account in the Links section of Account -> Profile. students need to put the URL of their kattis account in a link with the word "kattis" (in any case) in the title. this ends up being the join key for kattis2canvas.
|
|
71
|
+
|
|
72
|
+
you can check if the students have set up the links properly using
|
|
73
|
+
|
|
74
|
+
```
|
|
75
|
+
kattis2canvas kattislinks
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
# using kattis2canvas
|
|
79
|
+
|
|
80
|
+
## populating kattis assignments in canvas
|
|
81
|
+
|
|
82
|
+
when you create new kattis assignments, you will need to get it mirrored into canvas. currently the **course2canvas** command will put all of the assignments into an assignment group called kattis. the assignment group must be created in your course before **course2canvas**.
|
|
83
|
+
|
|
84
|
+
when you specify the names of offerings in kattis and courses in canvas, you can specify a substring of the name and **kattis2canvas** will be able to use it as long as it matches exactly one name. if it doesn't, it will show it what it found.
|
|
85
|
+
|
|
86
|
+
when creating the assignment in canvas, anything you have put in the description in kattis will also be replicated in canvas along with a link to the kattis assignment.
|
|
87
|
+
|
|
88
|
+
if you have made changes to a kattis assignment that you have already populated in canvas, use the --force option to force an update. (right now there isn't a way to force individual assignments.)
|
|
89
|
+
|
|
90
|
+
if you use modules, you can use the **--add-to-module** flag to add the kattis assignments to a module. at this point, it puts all of the kattis assignments into that one module.
|
|
91
|
+
|
|
92
|
+
## getting the results to canvas
|
|
93
|
+
|
|
94
|
+
the **submissions2canvas** will replicate results from student submissions to kattis into canvas. it will only replicate results that are either better or the same as or newer than previous results it has replicated. a summary of the problem, score, and link to the submission will be added as a comment for the student in the gradebook for the relevant assignment. the idea is that when it's time to grade, you have easy access to the results and the link to the source from canvas speedgrader.
|
|
95
|
+
|
|
96
|
+
# kattis webscraping
|
|
97
|
+
|
|
98
|
+
unfortuately, scraping the kattis API is very adhoc and it would be naive to think that it wouldn't change in ways that will break this tool. we use BeautifulSoupe (it's pretty beautiful...) so here are the features that the script relies on for kattis webpages: (note the term "assume" is used for things we have to believe because that is the only reasonable way that we can use the information we are given.)
|
|
99
|
+
|
|
100
|
+
* the list of offerings: (HOSTNAME is from the config file above) we assume http://HOSTNAME/ will give us a page will all the offerings and the urls in the href of those offerings will have the form **/courses/[^/]+/[^/]+**
|
|
101
|
+
* the list of assignments: we assume the offering page will have the detail page for assignments in hrefs of anchor tabs of the form "assignments/\w+$".
|
|
102
|
+
* the assignment details: we assume the assignment detail page will have an \<h2> tag with the text "Description" followed by a sibling \<p> tag that entirely contains the description. we also assume that there will be a \<td> for "start time" and another for "end time" we do case insensitve comparisons to find them. we assume that the following \<td> tage will have the time.
|
|
103
|
+
* time: TIME IS HARD! if the time is recent, kattis will drop the date, so if we get a time with no date, we take the current time and up date the HH:MM:SS with the date we get from kattis. we also assume all dates are UTC.
|
|
104
|
+
* getting submissions: we assume the submissions for an assignment are found at https://HOSTNAME/OFFERING/assignments/ASSIGNMENT_ID/submissions it appears that all submissions for the assignment are listed on that page. submissions for a problem outside of the assignment time period will not show up on that page. the submissions are in a table called "judege"table". the headers \<th> that we are looking for are "User" for the user url, "Problem" for the name of the problem, "Test cases" for the score reflected as sucess/count with -/- indicating no tries, and "" indicating the header for the url of the submission. once we know the column numbers we want, we have to look for the \<tbody> child of the table (problems happen if you try to look for \<td> recursively from the table!) then we look for \<tr> children of \<tbody> which have a "data-submission-id" attribute.
|
|
105
|
+
|
|
106
|
+
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
src/kattis2canvas/__init__.py
|
|
4
|
+
src/kattis2canvas/__main__.py
|
|
5
|
+
src/kattis2canvas/cli.py
|
|
6
|
+
src/kattis2canvas.egg-info/PKG-INFO
|
|
7
|
+
src/kattis2canvas.egg-info/SOURCES.txt
|
|
8
|
+
src/kattis2canvas.egg-info/dependency_links.txt
|
|
9
|
+
src/kattis2canvas.egg-info/entry_points.txt
|
|
10
|
+
src/kattis2canvas.egg-info/requires.txt
|
|
11
|
+
src/kattis2canvas.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
kattis2canvas
|