This document is to capture my initial attempts to transact data with the Queensland University of Technology (QUT) Canvas Learning Management System (LMS) via the Canvas LMS representational state transfer (REST) web application programming interface (API). I want to use R because it’s the language I know best, it is well suited to working with data, and many of the assignments I want to work with are in R.
I’m particularly keen to figure out how to
GET /api/schema/latest”rcanvas may be useful and……I’m short on time!
I was going to go with the httr2 and
jsonlite packages to roll my own Canvas requests, but with
time running out to get DSB100 assessments marked, I decided to see how
far I could get with rcanvas first, installing it from the
console using
What I’ve learned is that rcanvas is useful, but a bit
patchy. There are bugs, missing features, etc. The right thing to do
would be to contribute to the package’s development on github, but
again, time is short so I am adopting some stopgap solutions for the
moment.
As described here
You must first safely stash your Canvas API token into your operating system’s keyring. This only needs to be done once. To obtain the Canvas API token follow this trail in Canvas:
Canvas -> Account -> Settings -> Approved Integrations -> Add new token
You then need to run the following once in the R console:
set_canvas_token("YOUR_TOKEN_HERE")
I did this, and also saved the token I got from Canvas in my
.Renviron file as described here.
Note
that we need to tell rcanvas what Canvas domain we are
using. Here’s how for the QUT domain:
At one point, R started producing this error on startup:
Error in exists(cacheKey, where = .rs.WorkingDataEnv, inherits = FALSE) :
invalid first argument
It was reassuring to see rcanvas::get_course_list() work
first time
| id | course_code | name |
|---|---|---|
| 4089 | DSB100_21se2 | DSB100_21se2 Fundamentals of Data Science |
| 12878 | DSB100_22se2 | DSB100_22se2 Fundamentals of Data Science |
| 14567 | DSB100_23se2 | DSB100_23se2 Fundamentals of Data Science |
| 2792 | EGH400-1_23se1 | EGH400-1_23se1 Research Project 1 |
| 14584 | EGH400-1_23se2 | EGH400-1_23se2 Research Project 1 |
| 2796 | EGH400-2_23se1 | EGH400-2_23se1 Research Project 2 |
| 14585 | EGH400-2_23se2 | EGH400-2_23se2 Research Project 2 |
| 14587 | EGH408_23se2 | EGH408_23se2 Research Project |
| 14824 | IFN657_23se2 | IFN657_23se2 Principles of Software Security |
| 2927 | IFN703_23se1 | IFN703_23se1 Advanced Project |
| 14843 | IFN703_23se2 | IFN703_23se2 Advanced Project |
| 6034 | IFN704_21se1 | IFN704_21se1 Advanced Project 2 |
| 5821 | IFN704_21se2 | IFN704_21se2 Advanced Project 2 |
| 5824 | IFN704_22se1 | IFN704_22se1 Advanced Project 2 |
| 12674 | IFN704_22se2 | IFN704_22se2 Advanced Project 2 |
| 2929 | IFN704_23se1 | IFN704_23se1 Advanced Project 2 |
| 14616 | IFN704_23se2 | IFN704_23se2 Advanced Project 2 |
| 14635 | IFN712_23se2 | IFN712_23se2 Research in IT Practice |
| 350 | LTU_ITC100 | Introduction to Canvas - For Coordinators |
| 123 | LTU_AdvancedPageElements | LTU Canvas site styling resources |
| 10245 | Sandpit_lovelldr | Sandpit_lovelldr |
| 10375 | Training001_lovelldr | Training001_lovelldr |
idsCanvas uses two different kinds identifiers to specify different pieces of data::
_id,
a.k.a. legacy or traditional identifiers
idSince the numeric identifiers are all over the place in
rcanvas, I wrote some helper functions to work with
them.
list_courses()list_courses <- function(regex="."){
get_course_list() |>
select(id, course_code, name) |>
filter(str_detect(name, regex))
}…lists course_id and name pairs of names
that contain an optional regular expression, e.g.,
list_courses("DSB"):
| id | course_code | name |
|---|---|---|
| 4089 | DSB100_21se2 | DSB100_21se2 Fundamentals of Data Science |
| 12878 | DSB100_22se2 | DSB100_22se2 Fundamentals of Data Science |
| 14567 | DSB100_23se2 | DSB100_23se2 Fundamentals of Data Science |
get_course_id_of(regex=".")get_course_id_of <- function(regex="."){
get_course_list() |>
select(id, name) |>
filter(str_detect(name, regex)) |>
pull(id)
}…yields the course_ids of courses that I have access to
whose name(s) match an optional regular expression
[1] 4089 12878 14567 2792 14584 2796 14585 14587 14824 2927 14843 6034
[13] 5821 5824 12674 2929 14616 14635 350 123 10245 10375
[1] 14567
[1] 10245
get_course_folders(course_id)…returns information about all file folders for the specified
course_id:
get_course_folders <- function(course_id){
get_course_items(course_id = course_id, item="folders")
}Rows: 4
Columns: 22
$ id <int> 603765, 603766, 176869, 700250
$ name <chr> "Icons", "Uploaded Media", "course files", "tmp"
$ full_name <chr> "course files/Icons", "course files/Uploaded Media", …
$ context_id <int> 10245, 10245, 10245, 10245
$ context_type <chr> "Course", "Course", "Course", "Course"
$ parent_folder_id <int> 176869, 176869, NA, 176869
$ created_at <chr> "2022-06-23T22:44:02Z", "2022-05-26T02:13:00Z", "2022…
$ updated_at <chr> "2023-10-01T14:08:52Z", "2023-08-23T03:29:49Z", "2022…
$ lock_at <lgl> NA, NA, NA, NA
$ unlock_at <lgl> NA, NA, NA, NA
$ position <int> 2, 1, NA, 31
$ locked <lgl> TRUE, FALSE, FALSE, FALSE
$ folders_url <chr> "https://canvas.qut.edu.au/api/v1/folders/603765/fold…
$ files_url <chr> "https://canvas.qut.edu.au/api/v1/folders/603765/file…
$ files_count <int> 188, 192, 0, 4
$ folders_count <int> 0, 0, 3, 0
$ hidden <lgl> NA, TRUE, NA, NA
$ locked_for_user <lgl> FALSE, FALSE, FALSE, FALSE
$ hidden_for_user <lgl> FALSE, FALSE, FALSE, FALSE
$ for_submissions <lgl> FALSE, FALSE, FALSE, FALSE
$ can_upload <lgl> TRUE, TRUE, TRUE, TRUE
$ course_id <int> 10245, 10245, 10245, 10245
list_course_folders(course_id, regex=".")…lists folder_id and full_name pairs for
full_names that contain a given regular expression:
list_course_folders <- function(course_id, regex="."){
get_course_folders(course_id) |>
select(id, full_name, parent_folder_id) |>
filter(str_detect(full_name, regex))
} id full_name parent_folder_id
1 603765 course files/Icons 176869
2 603766 course files/Uploaded Media 176869
3 176869 course files NA
4 700250 course files/tmp 176869
get_folder_id_of(course_id, name=NA, full_name=NA)…returns the folder_id of a folder with the specified
name or full_name
get_folder_id_of <- function(course_id, name=NA, full_name=NA){
if(!is.na(name)){
get_course_folders(course_id) |>
select(id, name, full_name) |>
filter(.env$name==name) |> pull(id)
}
else if (!is.na(full_name)){
get_course_folders(course_id) |>
select(id, name, full_name) |>
filter(.env$full_name==full_name) |> pull(id)
}
}No names, no return value.
[1] 176869
[1] 176869
[1] 603765
rcanvas issuesrcanvas::create_course_folder()make_canvas_url on line 2 should not have
the first argument canvas_url()create_course_folder <- function(course_id, name, parent_folder_id = NULL) {
url <- make_canvas_url(canvas_url(), "courses", course_id, "folders")
args <- sc(list(name = name,
parent_folder_id = parent_folder_id))
invisible(canvas_query(url, args, "POST"))
message(sprintf("Folder %s created", name))
}Here is a quick fix. Note that Canvas adds a unique ascending digit to the folder name if the folder already exists.
my.create_course_folder <- function (course_id, name, parent_folder_id = NULL)
{
url <- rcanvas:::make_canvas_url("courses", course_id, "folders")
args <- rcanvas:::sc(list(name = name, parent_folder_id = parent_folder_id))
message(sprintf("Folder %s created", name))
invisible(rcanvas:::canvas_query(url, args, "POST"))
}Here’s the initial set of folders in my Sandpit:
id full_name parent_folder_id
1 603765 course files/Icons 176869
2 603766 course files/Uploaded Media 176869
3 176869 course files NA
4 700250 course files/tmp 176869
This is the first section of code that changes anything on Canvas.
eval=FALSEparent_folder_id == NULL
/course files/unfiled/course_idHere’s how to create course files/tmp
my.create_course_folder(
course_id = Sandpit,
name = "tmp",
parent_folder_id = get_folder_id_of(Sandpit, "course files")
)Note that the following this creates
/course files/tmp_tmp//course files/tmp/tmp/ as the message suggests…I’m nervous about the destructive potential of programmatic deletion, so I am using the Canvas web interface to delete files and folders at this point.
I’m not sure whether rcanvas supports the retrieval of
files from specific folders.
get_course_items(course_id, item, include = NULL) will
return data about various kinds of items
item="files" yields a lot of data for just my humble
sandpit, e.g.,# A tibble: 380 × 24
id uuid folder_id display_name filename upload_status `content-type` url size created_at updated_at
<int> <chr> <int> <chr> <chr> <chr> <chr> <chr> <int> <chr> <chr>
1 2708042 iDZ8RwsV… 603766 alarm-excla… alarm-e… success image/png http… 3560 2022-10-1… 2023-08-2…
2 2708036 UN6utaUP… 603766 alarm-excla… alarm-e… success image/png http… 3828 2022-10-1… 2023-08-2…
3 2708037 9y6ZwQ3F… 603766 alarm-excla… alarm-e… success image/png http… 4186 2022-10-1… 2023-08-2…
4 2708038 GQBhZcLz… 603766 alarm-excla… alarm-e… success image/png http… 4267 2022-10-1… 2023-08-2…
5 2708043 VVDgpBMZ… 603766 anchor_009F… anchor_… success image/png http… 2235 2022-10-1… 2023-08-2…
6 2708044 ckaLG0Bh… 603766 anchor_012A… anchor_… success image/png http… 2362 2022-10-1… 2023-08-2…
7 2708046 873yBp4v… 603766 anchor_C702… anchor_… success image/png http… 2484 2022-10-1… 2023-08-2…
8 2708045 V91aJF7Z… 603766 anchor_EFCB… anchor_… success image/png http… 2514 2022-10-1… 2023-08-2…
9 2708111 gW5w3xB7… 603766 arrow-down-… arrow-d… success image/png http… 1534 2022-10-1… 2023-08-2…
10 2708114 Ubjo4jok… 603766 arrow-down-… arrow-d… success image/png http… 1644 2022-10-1… 2023-08-2…
# ℹ 370 more rows
# ℹ 13 more variables: unlock_at <lgl>, locked <lgl>, hidden <lgl>, lock_at <lgl>, hidden_for_user <lgl>,
# thumbnail_url <chr>, modified_at <chr>, mime_class <chr>, media_entry_id <lgl>, category <chr>,
# locked_for_user <lgl>, visibility_level <chr>, course_id <int>
# ℹ Use `print(n = ...)` to see more rowsget_course_items()I’m not sure what the include argument does. And I’m not
sure what url is actually getting passed to Canvas. So I’m
going to write some code to try to get some insight.
The action seems to be happening in
get_course_items() which calls
process_response() which calls
canvas_query() which is where the url is
formed and sent to Canvaspaginate() then ensures that the (potentially very
long) response is compiledSo my strategy is to create my own versions of
get_course_items(), process_response() and
canvas_query() that I can instrument and figure out what’s
going on.
my.canvas_query()…is adapted from rcanvas/R/utils.R:
my.canvas_query <- function(urlx, args = NULL, type = "GET", verbose=FALSE) {
args <- rcanvas:::sc(args)
resp_fun_args <- list(
url = urlx,
httr::user_agent("rcanvas - https://github.com/daranzolin/rcanvas"),
httr::add_headers(Authorization = paste("Bearer", rcanvas:::check_token()))
)
if (type %in% c("POST", "PUT"))
resp_fun_args$body = args
else
resp_fun_args$query = args
if(verbose){
cat("> [my.canvas_query] str(resp_fun_args))\n")
str(resp_fun_args)
}
resp <- do.call(type, resp_fun_args, envir=rcanvas:::cdenv)
httr::stop_for_status(resp)
resp
}my.process_response()…is adapted from rcanvas/R/process_response.R
my.get_course_items()…is adapted from rcanvas/R/course-data.R
my.get_course_items <- function(course_id, item, include = NULL, verbose=FALSE) {
valid_items <- c(
"settings", "discussion_topics", "todo", "enrollments", "users", "students",
"features", "assignments", "files", "modules", "front_page", "pages", "quizzes",
"folders", "assignment_groups"
)
if (!missing(item) && !item %in% valid_items) {
stop(paste("item argument must be one of:", paste(valid_items, collapse = ", ")))
}
if (!missing(item)) {
url <- rcanvas:::make_canvas_url("courses", course_id, item)
} else {
#Omitting the item argument will return general information about the course
url <- rcanvas:::make_canvas_url("courses", course_id)
}
args <- list(per_page = 100)
include <- rcanvas:::iter_args_list(include, "include[]")
args <- c(args, include)
my.process_response(url, args, verbose=verbose) %>%
dplyr::mutate(course_id = course_id)
}I’ve set eval=FALSE to ensure that my API key is not
revealed when I knit this.
> [my.canvas_query] str(resp_fun_args))
List of 4
$ url : chr "https://canvas.qut.edu.au/api/v1/courses/10245/files"
$ :List of 7
..$ method : NULL
..$ url : NULL
..$ headers : NULL
..$ fields : NULL
..$ options :List of 1
.. ..$ useragent: chr "rcanvas - https://github.com/daranzolin/rcanvas"
..$ auth_token: NULL
..$ output : NULL
..- attr(*, "class")= chr "request"
$ :List of 7
..$ method : NULL
..$ url : NULL
..$ headers : Named chr "Bearer <My secret API key>"
.. ..- attr(*, "names")= chr "Authorization"
..$ fields : NULL
..$ options : NULL
..$ auth_token: NULL
..$ output : NULL
..- attr(*, "class")= chr "request"
$ query:List of 1
..$ per_page: num 100
These results tell me that
https://canvas.qut.edu.au/api/v1/courses/10245/files
GET /api/v1/courses/:course_id/filesGET /api/v1/folders/:id/files…so let’s try putting a file into a folder, then getting the
folder_id
upload_course_file(Sandpit,"CanvasAPI-BabySteps.Rmd",parent_folder_path = "/tmp")
(folder_id <- get_folder_id_of(Sandpit, full_name="course files/tmp"))…then making a url endpoint for that
folder_id and downloading information about the files it
contains:
my.get_folder_files(folder_id)my.get_folder_files <- function(folder_id) {
url <- rcanvas:::make_canvas_url("folders", folder_id, item="files")
args <- list(per_page = 100)
my.process_response(url, args) %>%
dplyr::mutate(folder_id = folder_id)
}et voila!
I’m nervous about the destructive potential of programmatic deletion, so I am using the Canvas web interface to delete files and folders at this point.
rcanvasrcanvas::upload_course_file()rcanvas::upload_course_file() has some issues which we
demonstrate here by:
CanvasAPI-BabySteps.Rmd) to
course files/tmp./CanvasAPI-BabySteps.Rmd) to
course files/tmpupload_course_file(Sandpit, "CanvasAPI-BabySteps.Rmd",parent_folder_path = "/tmp")
upload_course_file(Sandpit,"./CanvasAPI-BabySteps.Rmd",parent_folder_path = "/tmp")Looking at the files in course files/tmp shows that
rcanvas::upload_course_file() has used the local file path
as the destination filename and
display_name:
get_folder_id_of(Sandpit, full_name="course files/tmp") |>
my.get_folder_files() |>
select(folder_id, id, filename, display_name)I want to be able to upload files from any local path to the desired
Canvas folder so that their filename and
display_name are the local files’ basename,
e.g.,
So my strategy is to create my own version of
upload_course_file() to achieve that.
rcanvas::upload_course_file() calls
rcanvas::upload_file() which callsrcanvas::upload_content()my.upload_file()…adapted from rcanvas/R/uploads.R:
my.upload_file.A <- function(
url, local_file_path,
parent_folder_id = NULL, parent_folder_path = "/",
on_duplicate = "overwrite",
verbose=FALSE
) {
if (!is.null(parent_folder_id) && !is.null(parent_folder_path))
stop("Do not specify both parent folder id and parent folder path.")
file_size <- file.info(local_file_path)$size
args <- rcanvas:::sc(
list(
name = local_file_path,
size = file_size,
parent_folder_id = parent_folder_id,
parent_folder_path = parent_folder_path,
on_duplicate = on_duplicate
)
)
upload_resp <- my.canvas_query(url, args, "POST", verbose = verbose)
upload_content <- httr::content(upload_resp)
upload_url <- upload_content$upload_url
upload_params <- upload_content$upload_params
upload_params <- append(
upload_params,
list(file = httr::upload_file(local_file_path))
)
# This is not correct! We need to check the response of the post to ensure the file has been uploaded
# message(sprintf("File %s uploaded", local_file_path))
if(verbose){
cat("> [my.upload_file] str(upload_url)\n"); str(upload_url, nchar.max = 128)
cat("> [my.upload_file] str(upload_params)\n"); str(upload_params, nchar.max = 256)
}
invisible(httr::POST(url = upload_url, body = upload_params))
}my.upload_course_file()…adapted from rcanvas/R/uploads.R:
my.upload_course_file <- function(
course_id, local_file_path,
parent_folder_id = NULL,
parent_folder_path = "/",
on_duplicate = "overwrite",
verbose=FALSE) {
url <- rcanvas:::make_canvas_url("courses", course_id, "files")
my.upload_file.A(
url, local_file_path,
parent_folder_id, parent_folder_path, on_duplicate, verbose=verbose
)
}I’m nervous about the destructive potential of programmatic deletion, so I am using the Canvas web interface to delete files and folders at this point.
Let’s make a test file to upload
dir.create("./tmp", showWarnings = FALSE)
dir.create("./tmp/below", showWarnings = FALSE)
cat("Test file from CanvasAPI-BabySteps.Rmd\n", file = "./tmp/below/test.txt")If we run the following command
my.upload_course_file(
Sandpit,
"./tmp/below/test.txt",
parent_folder_path = "/tmp",
verbose = TRUE
)we get the following output (with API key obscured):
> [my.canvas_query] str(resp_fun_args))
List of 4
$ url : chr "https://canvas.qut.edu.au/api/v1/courses/10245/files"
$ :List of 7
..$ method : NULL
..$ url : NULL
..$ headers : NULL
..$ fields : NULL
..$ options :List of 1
.. ..$ useragent: chr "rcanvas - https://github.com/daranzolin/rcanvas"
..$ auth_token: NULL
..$ output : NULL
..- attr(*, "class")= chr "request"
$ :List of 7
..$ method : NULL
..$ url : NULL
..$ headers : Named chr "Bearer <My secret API key>"
.. ..- attr(*, "names")= chr "Authorization"
..$ fields : NULL
..$ options : NULL
..$ auth_token: NULL
..$ output : NULL
..- attr(*, "class")= chr "request"
$ body:List of 4
..$ name : chr "./CanvasAPI-BabySteps.Rmd"
..$ size : num 21048
..$ parent_folder_path: chr "/tmp"
..$ on_duplicate : chr "overwrite"
File ./CanvasAPI-BabySteps.Rmd uploaded
> [my.upload_file] str(upload_url)
chr "https://inst-fs-syd-prod.inscloudgate.net/files?token=eyJ0eXA"| __truncated__
> [my.upload_file] str(upload_params)
List of 3
$ filename : chr "./CanvasAPI-BabySteps.Rmd"
$ content_type: chr "unknown/unknown"
$ file :List of 3
..$ path: chr "C:\\Users\\<user id>\\<path>\\Canvas\\Canvas API\\CanvasAPI-BabySteps.Rmd"
..$ type: chr "text/x-markdown"
..$ name: NULL
..- attr(*, "class")= chr "form_file"
So it looks like the file names are set in the call to
my.canvas_query() in resp_fun_args$body
my.upload_file.B <- function(
url, local_file_path,
parent_folder_id = NULL, parent_folder_path = "/",
on_duplicate = "overwrite",
verbose=FALSE
) {
if (!is.null(parent_folder_id) && !is.null(parent_folder_path))
stop("Do not specify both parent folder id and parent folder path.")
file_size <- file.info(local_file_path)$size
name <- basename(local_file_path)
args <- rcanvas:::sc(
list(
name = name, # Try this!
size = file_size,
parent_folder_id = parent_folder_id,
parent_folder_path = parent_folder_path,
on_duplicate = on_duplicate
)
)
upload_resp <- my.canvas_query(url, args, "POST", verbose = verbose)
upload_content <- httr::content(upload_resp)
upload_url <- upload_content$upload_url
upload_params <- upload_content$upload_params
upload_params <- append(
upload_params, list(file = httr::upload_file(local_file_path))
)
message(sprintf("File %s uploaded", name))
if(verbose){
cat("> [my.upload_file] str(upload_url)\n"); str(upload_url, nchar.max = 128)
cat("> [my.upload_file] str(upload_params)\n"); str(upload_params, nchar.max = 256)
}
invisible(httr::POST(url = upload_url, body = upload_params))
}my.upload_course_file() revised…adapted from rcanvas/R/uploads.R:
my.upload_course_file <- function(
course_id, local_file_path,
parent_folder_id = NULL,
parent_folder_path = "/",
on_duplicate = "overwrite",
verbose=FALSE) {
url <- rcanvas:::make_canvas_url("courses", course_id, "files")
my.upload_file.B(
url, local_file_path,
parent_folder_id, parent_folder_path, on_duplicate, verbose=verbose
)
}rcanvas::upload_file() declares that the file has been
uploaded before it is sent to httr::POST
In the case of a 201 Created, the upload has been complete and the Canvas JSON representation of the file can be retrieved with a GET from the provided Location.
So we should be looking at the return value from
httr::POST, then figuring out whether the file has been
uploaded OK.
Let’s make another test file to upload
my.upload_course_file(
Sandpit, "./tmp/below/test2.txt",
parent_folder_path = "/tmp" #, verbose=TRUE
) -> respFile test2.txt uploaded
Let’s check resp:
List of 10
$ url : chr "https://inst-fs-syd-prod.inscloudgate.net/files?token=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzUxMiJ9.eyJpYXQiOjE2OTYyODU5"| __truncated__
$ status_code: int 201
$ headers :List of 12
..$ date : chr "Mon, 02 Oct 2023 22:32:34 GMT"
..$ content-type : chr "application/json; charset=utf-8"
..$ content-length : chr "924"
..$ connection : chr "keep-alive"
..$ server : chr "nginx"
..$ vary : chr "Origin"
..$ access-control-allow-credentials: chr "true"
..$ location : chr "https://canvas.qut.edu.au/api/v1/files/2973096?include%5B%5D=enhanced_preview_url"
..$ etag : chr "W/\"39c-UHIxg5SoS450gQR7hSsUnPdKgrI\""
..$ set-cookie : chr "inst-fs-session=eyJpZGVudGl0aWVzIjp7fX0=; path=/; expires=Tue, 03 Oct 2023 22:32:34 GMT; domain=inst-fs-syd-pro"| __truncated__
..$ set-cookie : chr "inst-fs-session.sig=BC9Cqtv-yOI6P61WEGVgSK31LQ0; path=/; expires=Tue, 03 Oct 2023 22:32:34 GMT; domain=inst-fs-"| __truncated__
..$ strict-transport-security : chr "max-age=31536000"
..- attr(*, "class")= chr [1:2] "insensitive" "list"
$ all_headers:List of 1
..$ :List of 3
.. ..$ status : int 201
.. ..$ version: chr "HTTP/1.1"
.. ..$ headers:List of 12
.. .. ..- attr(*, "class")= chr [1:2] "insensitive" "list"
$ cookies :'data.frame': 2 obs. of 7 variables:
..$ domain : chr [1:2] "#HttpOnly_.inst-fs-syd-prod.inscloudgate.net" "#HttpOnly_.inst-fs-syd-prod.inscloudgate.net"
..$ flag : logi [1:2] TRUE TRUE
..$ path : chr [1:2] "/" "/"
..$ secure : logi [1:2] TRUE TRUE
..$ expiration: POSIXct[1:2], format: "2023-10-04 08:32:34" "2023-10-04 08:32:34"
..$ name : chr [1:2] "inst-fs-session" "inst-fs-session.sig"
..$ value : chr [1:2] "eyJpZGVudGl0aWVzIjp7fX0=" "BC9Cqtv-yOI6P61WEGVgSK31LQ0"
$ content : raw [1:924] 7b 22 6c 6f ...
$ date : POSIXct[1:1], format: "2023-10-02 22:32:34"
$ times : Named num [1:6] 0 0.00435 0.02226 0.06574 0.06574 ...
..- attr(*, "names")= chr [1:6] "redirect" "namelookup" "connect" "pretransfer" ...
$ request :List of 7
..$ method : chr "POST"
..$ url : chr "https://inst-fs-syd-prod.inscloudgate.net/files?token=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzUxMiJ9.eyJpYXQiOjE2OTYyODU5"| __truncated__
..$ headers : Named chr "application/json, text/xml, application/xml, */*"
.. ..- attr(*, "names")= chr "Accept"
..$ fields :List of 3
.. ..$ filename : chr "test2.txt"
.. ..$ content_type: chr "text/plain"
.. ..$ file :List of 3
.. .. ..- attr(*, "class")= chr "form_file"
..$ options :List of 2
.. ..$ useragent: chr "libcurl/7.84.0 r-curl/5.0.2 httr/1.4.7"
.. ..$ post : logi TRUE
..$ auth_token: NULL
..$ output : list()
.. ..- attr(*, "class")= chr [1:2] "write_memory" "write_function"
..- attr(*, "class")= chr "request"
$ handle :Class 'curl_handle' <externalptr>
- attr(*, "class")= chr "response"
Let’s try checking that the file was uploaded using
resp$headers$location =
https://canvas.qut.edu.au/api/v1/files/2972712?include%5B%5D=enhanced_preview_url
resp in this knit)curl 'https://canvas.qut.edu.au/api/v1/files/2972712?include%5B%5D=enhanced_preview_url'\
-X POST \
-H 'Content-Length: 0' \
-H 'Authorization: Bearer <My secret API key>'which produces
{
"id": 2972712,
"uuid": "WopHtDcdJdp4OGUSHidJfaXfSC55h56HVuiEVajF",
"folder_id": 700250,
"display_name": "test2.txt",
"filename": "test2.txt",
"upload_status": "success",
"content-type": "text/plain",
"url": "https://canvas.qut.edu.au/files/2972712/download?download_frd=1\u0026verifier=WopHtDcdJdp4OGUSHidJfaXfSC55h56HVuiEVajF",
"size": 42,
"created_at": "2023-10-02T13:12:18Z",
"updated_at": "2023-10-02T13:12:18Z",
"unlock_at": null,
"locked": false,
"hidden": false,
"lock_at": null,
"hidden_for_user": false,
"thumbnail_url": null,
"modified_at": "2023-10-02T13:12:18Z",
"mime_class": "text",
"media_entry_id": null,
"category": "uncategorized",
"locked_for_user": false,
"visibility_level": "inherit",
"preview_url": "/courses/10245/files/2972712/file_preview?annotate=0",
"canvadoc_session_url": "/api/v1/canvadoc_session?blob=%7B%22user_id%22:218620000000001626,%22attachment_id%22:2972712,%22type%22:%22canvadoc%22%7D\u0026hmac=e4327ee44ee7a8f71036da73eb5d28198d771a28",
"crocodoc_session_url": null
}That’s better! It’s showing all the things we’d want to know about where the file has gone to. * Time for another rewrite of…
my.upload_file <- function(
url, local_file_path,
parent_folder_id = NULL, parent_folder_path = "/",
on_duplicate = "overwrite",
verbose=FALSE
) {
if (!is.null(parent_folder_id) && !is.null(parent_folder_path))
stop("Do not specify both parent folder id and parent folder path.")
file_size <- file.info(local_file_path)$size
name <- basename(local_file_path)
args <- rcanvas:::sc(
list(
name = name, # Try this!
size = file_size,
parent_folder_id = parent_folder_id,
parent_folder_path = parent_folder_path,
on_duplicate = on_duplicate
)
)
upload_resp <- my.canvas_query(url, args, "POST", verbose = verbose)
upload_content <- httr::content(upload_resp)
upload_url <- upload_content$upload_url
upload_params <- upload_content$upload_params
upload_params <- append(
upload_params, list(file = httr::upload_file(local_file_path))
)
if(verbose){
cat("> [my.upload_file] str(upload_url)\n"); str(upload_url, nchar.max = 128)
cat("> [my.upload_file] str(upload_params)\n"); str(upload_params, nchar.max = 256)
}
# Convert the json into a data frame
upload.response <- httr::POST(url = upload_url, body = upload_params)
rcanvas:::process_response(url=upload.response$headers$location, args=NULL)
}my.upload_course_file() revised againmy.upload_course_file <- function(
course_id, local_file_path,
parent_folder_id = NULL,
parent_folder_path = "/",
on_duplicate = "overwrite",
verbose=FALSE) {
url <- rcanvas:::make_canvas_url("courses", course_id, "files")
my.upload_file(
url, local_file_path,
parent_folder_id, parent_folder_path, on_duplicate, verbose=verbose
)
}my.upload_course_file(
Sandpit, "./tmp/below/test2.txt",
parent_folder_path = "/tmp" #, verbose=TRUE
) -> resp
glimpse(resp)Rows: 1
Columns: 21
$ id <int> 2972862
$ uuid <chr> "Ss4vIxthCaMDomTsq8qSZ1dUh7GsmHCU6AcvHB1I"
$ folder_id <int> 700250
$ display_name <chr> "test2.txt"
$ filename <chr> "test2.txt"
$ upload_status <chr> "success"
$ `content-type` <chr> "text/plain"
$ url <chr> "https://canvas.qut.edu.au/files/2972862/download?download_fr~
$ size <int> 42
$ created_at <chr> "2023-10-02T13:57:14Z"
$ updated_at <chr> "2023-10-02T13:57:14Z"
$ locked <lgl> FALSE
$ hidden <lgl> FALSE
$ hidden_for_user <lgl> FALSE
$ modified_at <chr> "2023-10-02T13:57:13Z"
$ mime_class <chr> "text"
$ category <chr> "uncategorized"
$ locked_for_user <lgl> FALSE
$ visibility_level <chr> "inherit"
$ preview_url <chr> "/courses/10245/files/2972862/file_preview?annotate=0"
$ canvadoc_session_url <chr> "/api/v1/canvadoc_session?blob=%7B%22user_id%22:2186200000000~Yay!!!
rcanvas::get_assignment_list() works straight out of the
box with the one test assignment I created in my sandpit
Rows: 1
Columns: 65
$ id <int> 152713
$ description <chr> "<link rel=\"stylesheet\" href=\"…
$ due_at <lgl> NA
$ unlock_at <lgl> NA
$ lock_at <lgl> NA
$ points_possible <dbl> 10
$ grading_type <chr> "points"
$ assignment_group_id <int> 36540
$ grading_standard_id <lgl> NA
$ created_at <chr> "2023-09-29T22:12:10Z"
$ updated_at <chr> "2023-10-02T14:46:23Z"
$ peer_reviews <lgl> FALSE
$ automatic_peer_reviews <lgl> FALSE
$ position <int> 2
$ grade_group_students_individually <lgl> FALSE
$ anonymous_peer_reviews <lgl> FALSE
$ group_category_id <lgl> NA
$ post_to_sis <lgl> FALSE
$ moderated_grading <lgl> FALSE
$ omit_from_final_grade <lgl> FALSE
$ intra_group_peer_reviews <lgl> FALSE
$ anonymous_instructor_annotations <lgl> FALSE
$ anonymous_grading <lgl> FALSE
$ graders_anonymous_to_graders <lgl> FALSE
$ grader_count <int> 0
$ grader_comments_visible_to_graders <lgl> TRUE
$ final_grader_id <lgl> NA
$ grader_names_visible_to_final_grader <lgl> TRUE
$ allowed_attempts <int> -1
$ annotatable_attachment_id <lgl> NA
$ hide_in_gradebook <lgl> FALSE
$ secure_params <chr> "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1…
$ lti_context_id <chr> "0609db8e-1d32-4e20-bc59-4003eaab…
$ course_id <int> 10245
$ name <chr> "Test Assignment"
$ submission_types <list> "online_text_entry"
$ has_submitted_submissions <lgl> TRUE
$ due_date_required <lgl> FALSE
$ max_name_length <int> 255
$ in_closed_grading_period <lgl> FALSE
$ graded_submissions_exist <lgl> TRUE
$ is_quiz_assignment <lgl> FALSE
$ can_duplicate <lgl> TRUE
$ original_course_id <lgl> NA
$ original_assignment_id <lgl> NA
$ original_lti_resource_link_id <lgl> NA
$ original_assignment_name <lgl> NA
$ original_quiz_id <lgl> NA
$ workflow_state <chr> "published"
$ important_dates <lgl> FALSE
$ muted <lgl> FALSE
$ html_url <chr> "https://canvas.qut.edu.au/course…
$ has_overrides <lgl> FALSE
$ needs_grading_count <int> 0
$ sis_assignment_id <lgl> NA
$ integration_id <lgl> NA
$ published <lgl> TRUE
$ unpublishable <lgl> FALSE
$ only_visible_to_overrides <lgl> FALSE
$ locked_for_user <lgl> FALSE
$ submissions_download_url <chr> "https://canvas.qut.edu.au/course…
$ post_manually <lgl> FALSE
$ anonymize_students <lgl> FALSE
$ require_lockdown_browser <lgl> FALSE
$ restrict_quantitative_data <lgl> FALSE
…and looks great when presented with kable
| id | name | due_at |
|---|---|---|
| 152713 | Test Assignment | NA |
rcanvas::get_assignment_list() also works nicely with
the one test submission to my one test assignment
get_assignment_list(Sandpit) |> pull(id) -> TestAssignment
glimpse(get_submissions(Sandpit, "assignments", TestAssignment))Rows: 1
Columns: 31
$ id <int> 4627314
$ body <chr> "<link rel=\"stylesheet\" href=\"http…
$ url <lgl> NA
$ grade <chr> "11"
$ score <dbl> 11
$ submitted_at <chr> "2023-09-29T22:13:31Z"
$ assignment_id <int> 152713
$ user_id <int> 74948
$ submission_type <chr> "online_text_entry"
$ workflow_state <chr> "graded"
$ grade_matches_current_submission <lgl> TRUE
$ graded_at <chr> "2023-10-02T14:46:18Z"
$ grader_id <int> 1626
$ attempt <int> 2
$ cached_due_date <lgl> NA
$ excused <lgl> FALSE
$ late_policy_status <lgl> NA
$ points_deducted <lgl> NA
$ grading_period_id <lgl> NA
$ extra_attempts <lgl> NA
$ posted_at <chr> "2023-10-02T04:57:14Z"
$ redo_request <lgl> FALSE
$ custom_grade_status_id <lgl> NA
$ sticker <lgl> NA
$ late <lgl> FALSE
$ missing <lgl> FALSE
$ seconds_late <int> 0
$ entered_grade <chr> "11"
$ entered_score <dbl> 11
$ preview_url <chr> "https://canvas.qut.edu.au/courses/10…
$ course_id <int> 10245
my.display_grade()… is a helper function to display grade information from the output
of get_submissions()
rcanvas::get_submissions()Here’s some information about the one test submission to my one test assignment
| id | course_id | assignment_id | user_id | grade | entered_grade | score | entered_score |
|---|---|---|---|---|---|---|---|
| 4627314 | 10245 | 152713 | 74948 | 11 | 11 | 11 | 11 |
I’m not really sure what the difference is between a
grade and score, but fortunately, the Canvas
API documentation explains that when it describes what A
Submission object looks like:
{
// The submission's assignment id
"assignment_id": 23,
// The submission's assignment (see the assignments API) (optional)
"assignment": null,
// The submission's course (see the course API) (optional)
"course": null,
// This is the submission attempt number.
"attempt": 1,
// The content of the submission, if it was submitted directly in a text field.
"body": "There are three factors too...",
// The grade for the submission, translated into the assignment grading scheme
// (so a letter grade, for example).
"grade": "A-",
// A boolean flag which is false if the student has re-submitted since the
// submission was last graded.
"grade_matches_current_submission": true,
// URL to the submission. This will require the user to log in.
"html_url": "http://example.com/courses/255/assignments/543/submissions/134",
// URL to the submission preview. This will require the user to log in.
"preview_url": "http://example.com/courses/255/assignments/543/submissions/134?preview=1",
// The raw score
"score": 13.5,
// Associated comments for a submission (optional)
"submission_comments": null,
// The types of submission ex:
// ('online_text_entry'|'online_url'|'online_upload'|'online_quiz'|'media_record
// ing'|'student_annotation')
"submission_type": "online_text_entry",
// The timestamp when the assignment was submitted
"submitted_at": "2012-01-01T01:00:00Z",
// The URL of the submission (for 'online_url' submissions).
"url": null,
// The id of the user who created the submission
"user_id": 134,
// The id of the user who graded the submission. This will be null for
// submissions that haven't been graded yet. It will be a positive number if a
// real user has graded the submission and a negative number if the submission
// was graded by a process (e.g. Quiz autograder and autograding LTI tools).
// Specifically autograded quizzes set grader_id to the negative of the quiz id.
// Submissions autograded by LTI tools set grader_id to the negative of the tool
// id.
"grader_id": 86,
"graded_at": "2012-01-02T03:05:34Z",
// The submissions user (see user API) (optional)
"user": null,
// stuff deleted
}Having read that, I think that what I need to do is get a single submission before I try to give it a score.
rcanvas::get_submission_single()Let’s try to get information about a single submission, namely, that
of the first student in the list of submissions to the
TestAssignment in my Sandpit:
get_submissions(Sandpit, "assignments", TestAssignment) |>
pull(user_id) |> first() -> Student001
get_submission_single(Sandpit, "assignments", TestAssignment, Student001) |>
glimpse()List of 30
$ id : int 4627314
$ body : chr "<link rel=\"stylesheet\" href=\"https://instructure-uploads-apse2.s3.ap-southeast-2.amazonaws.com/account_21862"| __truncated__
$ url : NULL
$ grade : chr "11"
$ score : num 11
$ submitted_at : chr "2023-09-29T22:13:31Z"
$ assignment_id : int 152713
$ user_id : int 74948
$ submission_type : chr "online_text_entry"
$ workflow_state : chr "graded"
$ grade_matches_current_submission: logi TRUE
$ graded_at : chr "2023-10-02T14:46:18Z"
$ grader_id : int 1626
$ attempt : int 2
$ cached_due_date : NULL
$ excused : logi FALSE
$ late_policy_status : NULL
$ points_deducted : NULL
$ grading_period_id : NULL
$ extra_attempts : NULL
$ posted_at : chr "2023-10-02T04:57:14Z"
$ redo_request : logi FALSE
$ custom_grade_status_id : NULL
$ sticker : NULL
$ late : logi FALSE
$ missing : logi FALSE
$ seconds_late : int 0
$ entered_grade : chr "11"
$ entered_score : num 11
$ preview_url : chr "https://canvas.qut.edu.au/courses/10245/assignments/152713/submissions/74948?preview=1&version=20"
…actually that looks pretty similar to what I was getting from
get_submissions()
[1] "id" "body"
[3] "url" "grade"
[5] "score" "submitted_at"
[7] "assignment_id" "user_id"
[9] "submission_type" "workflow_state"
[11] "grade_matches_current_submission" "graded_at"
[13] "grader_id" "attempt"
[15] "cached_due_date" "excused"
[17] "late_policy_status" "points_deducted"
[19] "grading_period_id" "extra_attempts"
[21] "posted_at" "redo_request"
[23] "custom_grade_status_id" "sticker"
[25] "late" "missing"
[27] "seconds_late" "entered_grade"
[29] "entered_score" "preview_url"
[31] "course_id"
[1] "id" "body"
[3] "url" "grade"
[5] "score" "submitted_at"
[7] "assignment_id" "user_id"
[9] "submission_type" "workflow_state"
[11] "grade_matches_current_submission" "graded_at"
[13] "grader_id" "attempt"
[15] "cached_due_date" "excused"
[17] "late_policy_status" "points_deducted"
[19] "grading_period_id" "extra_attempts"
[21] "posted_at" "redo_request"
[23] "custom_grade_status_id" "sticker"
[25] "late" "missing"
[27] "seconds_late" "entered_grade"
[29] "entered_score" "preview_url"
…and when I check the code in GitHub rcanvas/R/submissions.R
I can see that they are pretty much the same call.
What I really want is to get and set submission comments, grades and file attachments. I think I need to look at
Hmmm… looking at Get a single submission, I think I’m understanding how to construct the required URL:
GET /api/v1/courses/:course_id/assignments/:assignment_id/submissions/:user_id# 1. get the desired course_id (I'll use my helper function to get Sandpit)
get_course_id_of("Sandpit") -> course_id
# 2. get the desired assignment_id (I'll just take the first)
get_assignment_list(course_id) |>
pull(id) |> first() -> assignment_id
# 3. get the desired user_id (I'll just take the first)
get_submissions(course_id, "assignments", assignment_id) |>
pull(user_id) |> first() -> user_id
# 4. make the url
(rcanvas:::make_canvas_url(
"courses", course_id,
"assignments", assignment_id,
"submissions", user_id) -> url)[1] "https://canvas.qut.edu.au/api/v1/courses/10245/assignments/152713/submissions/74948"
That looks plausible! Now the API allows urls to be called with parameters.
rcanvas refers to these as
argumentsRows: 1
Columns: 22
$ id <int> 4627314
$ body <chr> "<link rel=\"stylesheet\" href=\"http…
$ grade <chr> "11"
$ score <dbl> 11
$ submitted_at <chr> "2023-09-29T22:13:31Z"
$ assignment_id <int> 152713
$ user_id <int> 74948
$ submission_type <chr> "online_text_entry"
$ workflow_state <chr> "graded"
$ grade_matches_current_submission <lgl> TRUE
$ graded_at <chr> "2023-10-02T14:46:18Z"
$ grader_id <int> 1626
$ attempt <int> 2
$ excused <lgl> FALSE
$ posted_at <chr> "2023-10-02T04:57:14Z"
$ redo_request <lgl> FALSE
$ late <lgl> FALSE
$ missing <lgl> FALSE
$ seconds_late <int> 0
$ entered_grade <chr> "11"
$ entered_score <dbl> 11
$ preview_url <chr> "https://canvas.qut.edu.au/courses/10…
That looks pretty good! Now let’s try adding arguments…
include[] parameter,
which I think rcanvas creates like this:$`include[]`
[1] "submission_comments"
Here’s the call:
Rows: 25
Columns: 23
$ id <int> 4627314, 4627314, 4627314, 4627314, 4…
$ body <chr> "<link rel=\"stylesheet\" href=\"http…
$ grade <chr> "11", "11", "11", "11", "11", "11", "…
$ score <dbl> 11, 11, 11, 11, 11, 11, 11, 11, 11, 1…
$ submitted_at <chr> "2023-09-29T22:13:31Z", "2023-09-29T2…
$ assignment_id <int> 152713, 152713, 152713, 152713, 15271…
$ user_id <int> 74948, 74948, 74948, 74948, 74948, 74…
$ submission_type <chr> "online_text_entry", "online_text_ent…
$ workflow_state <chr> "graded", "graded", "graded", "graded…
$ grade_matches_current_submission <lgl> TRUE, TRUE, TRUE, TRUE, TRUE, TRUE, T…
$ graded_at <chr> "2023-10-02T14:46:18Z", "2023-10-02T1…
$ grader_id <int> 1626, 1626, 1626, 1626, 1626, 1626, 1…
$ attempt <int> 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2…
$ excused <lgl> FALSE, FALSE, FALSE, FALSE, FALSE, FA…
$ posted_at <chr> "2023-10-02T04:57:14Z", "2023-10-02T0…
$ redo_request <lgl> FALSE, FALSE, FALSE, FALSE, FALSE, FA…
$ late <lgl> FALSE, FALSE, FALSE, FALSE, FALSE, FA…
$ missing <lgl> FALSE, FALSE, FALSE, FALSE, FALSE, FA…
$ seconds_late <int> 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0…
$ entered_grade <chr> "11", "11", "11", "11", "11", "11", "…
$ entered_score <dbl> 11, 11, 11, 11, 11, 11, 11, 11, 11, 1…
$ preview_url <chr> "https://canvas.qut.edu.au/courses/10…
$ submission_comments <df[,16]> <data.frame[25 x 16]>
Now this time, resp has a
submission_comments element attached:
Rows: 25
Columns: 16
$ id <int> 382989, 382991, 382992, 389154, 389175, 389305…
$ comment <chr> "A comment on my first attempt", "Comment #1 o…
$ author_id <int> 74948, 74948, 74948, 1626, 1626, 1626, 1626, 1…
$ author_name <chr> "Test student", "Test student", "Test student"…
$ created_at <chr> "2023-09-29T22:13:08Z", "2023-09-29T22:13:54Z"…
$ edited_at <lgl> NA, NA, NA, NA, NA, NA, NA, NA, NA, NA, NA, NA…
$ attempt <int> 1, 2, 2, NA, NA, NA, 2, 2, NA, NA, NA, NA, NA,…
$ avatar_path <chr> "/images/users/74948-6c6f9b017f", "/images/use…
$ attachments <list> <NULL>, <NULL>, <NULL>, <NULL>, <NULL>, <NULL…
$ author.id <int> 74948, 74948, 74948, 1626, 1626, 1626, 1626, 1…
$ author.anonymous_id <chr> "1ltw", "1ltw", "1ltw", "196", "196", "196", "…
$ author.display_name <chr> "Test student", "Test student", "Test student"…
$ author.avatar_image_url <chr> "https://canvas.qut.edu.au/images/messages/ava…
$ author.html_url <chr> "https://canvas.qut.edu.au/courses/10245/users…
$ author.pronouns <lgl> NA, NA, NA, NA, NA, NA, NA, NA, NA, NA, NA, NA…
$ author.fake_student <lgl> TRUE, TRUE, TRUE, NA, NA, NA, NA, NA, NA, NA, …
However, when I try to include more than the
submission_comments in my call, things don’t work
include <- c("submission_comments", "user")
(args <- rcanvas:::iter_args_list(include, "include[]"))
resp <- my.process_response(url, args, verbose=FALSE)Error in `vctrs::data_frame()`:
! Can't recycle `id` (size 3) to match `user` (size 9).
Run `rlang::last_trace()` to see where the error occurred.
> rlang::last_trace()
<error/vctrs_error_incompatible_size>
Error in `vctrs::data_frame()`:
! Can't recycle `id` (size 3) to match `user` (size 9).
---
Backtrace:
▆
1. ├─global my.process_response(url, args, verbose = FALSE)
2. │ └─... %>% dplyr::bind_rows()
3. └─dplyr::bind_rows(.)
4. └─vctrs::data_frame(!!!.x, .name_repair = "minimal")
Run rlang::last_trace(drop = FALSE) to see 4 hidden frames.
Yet when I try the same thing from the command line
curl 'https://canvas.qut.edu.au/api/v1/courses/10245/assignments/152713/submissions/74948'\
-X GET \
-H 'Authorization: Bearer <My secret API key>' \
-d 'include[]=submission_comments' \
-d 'include[]=user'…I get a nice big wodge of json which suggests that
things are working just fine with the call
{
"id": 4627314,
<stuff deleted>,
"assignment_id": 152713,
"user_id": 74948,
"submission_type": "online_text_entry",
<stuff deleted>,
"submission_comments": [
{
"id": 382989,
"comment": "A comment on my first attempt",
"author_id": 74948,
"author_name": "Test student",
<stuff deleted>
},
{
"id": 382991,
"comment": "Comment #1 on my second attempt",
"author_id": 74948,
"author_name": "Test student",
<stuff deleted>
},
{
"id": 382992,
"comment": "Comment #2 on my second attempt",
"author_id": 74948,
"author_name": "Test student",
<stuff deleted>
}
],
"user": {
"id": 74948,
"name": "Test student",
<stuff deleted>
}
}The problem looks to be around the bind_rows() in the
following:
my.process_response <- function(url, args, verbose=FALSE) {
resp <- my.canvas_query(url, args, "GET", verbose=verbose)
rcanvas:::paginate(resp, showProgress = FALSE) %>%
purrr::map(httr::content, "text") %>%
purrr::map(jsonlite::fromJSON, flatten = TRUE) %>%
dplyr::bind_rows()
}Looking back at the json, I think I can see why there is
a problem.
submission_comments and
user information to be returned with the submissionssubmission_id in which was nested
comment_ids on that submissionuser_id for that submissionTime to move on! I think I will have to content myself with just
rcanvas::comment_submission())rcanvas::grade_submission()… or
should that be score?)rcanvas::grade_submission() Take 1The code
for rcanvas::grade_submission()
submission[posted_grade]` = grade on
line
105.scoresubmission[posted_grade] that this callAssigns a score to the submission, updating both the
scoreandgradefields on the submission record. This parameter can be passed in a few different formats:
- points
- A floating point or integral value, such as “13.5”. The grade will be interpreted directly as the score of the assignment.
So let’s give it a red-hot go! But first, I’m going to write…
my.get_submission()Looking at the code on GitHub, I’m not sure why the authors didn’t
use process_response() at line
46 of rcanvas::get_submission_single() as they did at
line
26 of rcanvas::get_submissions()
rcanvas::get_submissions() returns a tibble, which is
easier to deal with than the list from
rcanvas::get_submission_single()rcanvas::get_submissions()
approach in getting one submission (singular)
my.get_submission <- function(course_id, assignment_id, user_id, verbose=FALSE) {
# See https://canvas.instructure.com/doc/api/submissions.html#method.submissions_api.show
url <- rcanvas:::make_canvas_url(
'courses', course_id,
'assignments', assignment_id,
'submissions', user_id
)
args <- list(
access_token = rcanvas:::check_token(),
per_page = 100
)
my.process_response(url, args, verbose=verbose) |>
dplyr::mutate(course_id = course_id)
}Now, to try it out. First, I set the required ids:
course_id <- get_course_id_of("Sandpit")
assignment_id <- get_assignment_list(course_id) |> pull(id) |> first()
user_id <- get_submissions(course_id, "assignments", assignment_id) |> pull(user_id) |> first()…then make the call
(
submission <-
my.get_submission(
course_id = course_id,
assignment_id = assignment_id,
user_id = user_id
)
) |> glimpse()Rows: 1
Columns: 23
$ id <int> 4627314
$ body <chr> "<link rel=\"stylesheet\" href=\"http…
$ grade <chr> "11"
$ score <dbl> 11
$ submitted_at <chr> "2023-09-29T22:13:31Z"
$ assignment_id <int> 152713
$ user_id <int> 74948
$ submission_type <chr> "online_text_entry"
$ workflow_state <chr> "graded"
$ grade_matches_current_submission <lgl> TRUE
$ graded_at <chr> "2023-10-02T14:46:18Z"
$ grader_id <int> 1626
$ attempt <int> 2
$ excused <lgl> FALSE
$ posted_at <chr> "2023-10-02T04:57:14Z"
$ redo_request <lgl> FALSE
$ late <lgl> FALSE
$ missing <lgl> FALSE
$ seconds_late <int> 0
$ entered_grade <chr> "11"
$ entered_score <dbl> 11
$ preview_url <chr> "https://canvas.qut.edu.au/courses/10…
$ course_id <int> 10245
Success!!!
rcanvas::grade_submission() Take 2Check the current grade and score of the submission:
# A tibble: 1 × 2
grade score
<chr> <dbl>
1 11 11
Whoa! grade is a string! This means we have to pass in
numerical values as strings. Aiee! And also grade can be
NULL. OK, so here is some code to create a string called
newgrade which will be
grade is NULL or non-numericgradegrade) + 1),
otherwisenewgrade <-
submission |>
select(grade) |>
mutate(
newgrade=case_when(
is.null(grade) ~ "0",
is.na(as.numeric(grade)) ~ "0",
.default = as.character(as.numeric(grade) + 1)
)
) |> pull(newgrade)Let’s display the grade information of the current submission, put the new grade in and see what happens:
# Display the current submission's grade information
my.get_submission(course_id, assignment_id, user_id) |> my.display_grade()| id | course_id | assignment_id | user_id | grade | entered_grade | score | entered_score |
|---|---|---|---|---|---|---|---|
| 4627314 | 10245 | 152713 | 74948 | 11 | 11 | 11 | 11 |
# Update the submission's grade information on Canvas
grade_submission( course_id, assignment_id, user_id, newgrade)# Display the updated grade information
my.get_submission(course_id, assignment_id, user_id) |> my.display_grade()| id | course_id | assignment_id | user_id | grade | entered_grade | score | entered_score |
|---|---|---|---|---|---|---|---|
| 4627314 | 10245 | 152713 | 74948 | 12 | 12 | 12 | 12 |
It looks like the following API calls are relevant::
I searched high and low for information about how to get the file ids associated with a submission’s comments. I looked in the All API Resources for the following terms
/api/v1/courses/:course_id/assignments/:assignment_id/submissions/:user_id/commentsGET /api/v1/courses/:course_id/assignments/:assignment_id/submissions/:user_id/PUT /api/v1/courses/:course_id/assignments/:assignment_id/submissions/:user_id/submissions/:user_id/commentssubmissions/:user_id/Returns a SubmissionAs far as I can see, the API does not allow us to see what file ids
are associated with submission comments, even though there is a
way to put a file_id against a comment.
It looks like the only way to tell if there is a file attached is via the web interface.
/course files# Display the updated comment information
(comments <- my.get_submission_comments(course_id, assignment_id, user_id)) |>
head(8) |> my.display_comments() |> kable_styling(font_size = 11)| id | created_at | author_id | author_name | comment |
|---|---|---|---|---|
| 382989 | 2023-09-29T22:13:08Z | 74948 | Test student | A comment on my first attempt |
| 382991 | 2023-09-29T22:13:54Z | 74948 | Test student | Comment #1 on my second attempt |
| 382992 | 2023-09-29T22:14:07Z | 74948 | Test student | Comment #2 on my second attempt |
| 389154 | 2023-10-02T06:48:14Z | 1626 | David Lovell | A comment made at 2023-10-02 16:48:14.483596 |
| 389175 | 2023-10-02T06:52:17Z | 1626 | David Lovell | A comment made at 2023-10-02 16:52:17.843728 |
| 389305 | 2023-10-02T07:37:57Z | 1626 | David Lovell | A comment made at 2023-10-02 17:37:58.467678 |
| 389383 | 2023-10-02T08:12:42Z | 1626 | David Lovell | Here’s a comment with a single file attachment. |
| 389386 | 2023-10-02T08:14:00Z | 1626 | David Lovell | Here’s another comment with a different single file attachment. |
https://canvas.qut.edu.au/courses/10245/assignments/152713/submissions/74948?comment_id=389383&download=2972077https://canvas.qut.edu.au/courses/10245/assignments/152713/submissions/74948?comment_id=389386&download=2972081course_id: 10245assignment_id: 152713author_id: 74948 (“Test student”)
author_id is
1626; my author_name is “David Lovell”)comment_ids: 389383, 389386download=,
i.e., 2972077, 2972081curl 'https://canvas.qut.edu.au/api/v1/files/2972077'\
-X GET \
-H 'Authorization: Bearer <My secret API key>'{
"id": 2972077,
"uuid": "HR5QTA30aumX9JcoCYQ130ymtDA40Ar9ww3U0eQr",
"folder_id": null,
"display_name": "Test01.txt",
"filename": "Test01.txt",
"upload_status": "success",
"content-type": "text/plain",
"url": "https://canvas.qut.edu.au/files/2972077/download?download_frd=1\u0026verifier=HR5QTA30aumX9JcoCYQ130ymtDA40Ar9ww3U0eQr",
"size": 51,
"created_at": "2023-10-02T08:12:42Z",
"updated_at": "2023-10-02T08:12:42Z",
"unlock_at": null,
"locked": false,
"hidden": false,
"lock_at": null,
"hidden_for_user": false,
"thumbnail_url": null,
"modified_at": "2023-10-02T08:12:42Z",
"mime_class": "text",
"media_entry_id": null,
"category": "uncategorized",
"locked_for_user": false,
"canvadoc_session_url": "/api/v1/canvadoc_session?blob=%7B%22user_id%22:218620000000001626,%22attachment_id%22:2972077,%22type%22:%22canvadoc%22%7D\u0026hmac=4fae2db4c24401ed86133e87785262458e8324c1",
"crocodoc_session_url": null
}file_id of a file in my
/course files foldercat("Test # 03\n\nA third Test file to attach to Canvas Comment.", file = "./tmp/Test03.txt")
cat("Test # 04\n\nA third Test file to attach to Canvas Comment.", file = "./tmp/Test04.txt")# A tibble: 1 × 21
id uuid folder_id display_name filename upload_status `content-type`
<int> <chr> <int> <chr> <chr> <chr> <chr>
1 2973098 9n3lday7… 700250 Test03.txt Test03.… success text/plain
# ℹ 14 more variables: url <chr>, size <int>, created_at <chr>,
# updated_at <chr>, locked <lgl>, hidden <lgl>, hidden_for_user <lgl>,
# modified_at <chr>, mime_class <chr>, category <chr>, locked_for_user <lgl>,
# visibility_level <chr>, preview_url <chr>, canvadoc_session_url <chr>
# A tibble: 1 × 21
id uuid folder_id display_name filename upload_status `content-type`
<int> <chr> <int> <chr> <chr> <chr> <chr>
1 2973099 QS3Og2B8… 700250 Test04.txt Test04.… success text/plain
# ℹ 14 more variables: url <chr>, size <int>, created_at <chr>,
# updated_at <chr>, locked <lgl>, hidden <lgl>, hidden_for_user <lgl>,
# modified_at <chr>, mime_class <chr>, category <chr>, locked_for_user <lgl>,
# visibility_level <chr>, preview_url <chr>, canvadoc_session_url <chr>
file_ids <-
get_folder_id_of(Sandpit, full_name="course files/tmp") |>
my.get_folder_files() |>
filter(str_detect(display_name, "Test0[34].txt")) |>
pull(id)Grade
or comment on a submission explains the right PUT:
PUT /api/v1/courses/:course_id/assignments/:assignment_id/submissions/:user_id
comment[text_comment] string
comment[file_ids][] integer
So let’s try
my.comment_submission <- function(
course_id, assignment_id, user_id, comment_str,
file_ids = NULL, to_group = TRUE, visible = TRUE,
verbose = FALSE) {
url <- rcanvas:::make_canvas_url(
"courses", course_id,
"assignments", assignment_id,
"submissions", user_id)
args <- list(
access_token = rcanvas:::check_token(),
`comment[text_comment]` = comment_str,
`comment[group_comment]` = to_group,
`include[visibility]` = visible,
per_page = 100)
if(!is.null(file_ids))
args <- c(
args, rcanvas:::iter_args_list(file_ids, "comment[file_ids][]")
)
invisible(my.canvas_query(url, args, "PUT", verbose = verbose))
}Let’s try it without any file_ids
comment_str <- paste0("A comment made at ", now())
my.comment_submission(course_id, assignment_id, user_id, comment_str) -> respGreat! Let’s try it with file_ids that had been uploaded
manually previously:
comment_str <- paste0("A comment made at ", now())
my.comment_submission(course_id, assignment_id, user_id, comment_str, file_ids=c(2972077, 2972081)) -> respGreat! Now let’s try it with file_ids that I got from
the upload to the course files:
comment_str <- paste0("A comment made at ", now())
my.comment_submission(course_id, assignment_id, user_id, comment_str, file_ids=file_ids) -> respHTTP 403 means the server understands the query but
refuses to do it. Dang!
comment[file_ids][] integer
That may be the issue! I was uploading them using the plain old Files API
my.upload_submission_comment_file()…adapted from my.upload_course_file() above
my.upload_submission_comment_file <- function(
course_id, assignment_id, user_id, local_file_path,
parent_folder_id = NULL,
parent_folder_path = NULL,
on_duplicate = "overwrite",
verbose=FALSE) {
# POST /api/v1/courses/:course_id/assignments/:assignment_id/dsubmissions/:user_i/comments/files
url <- rcanvas:::make_canvas_url(
"courses", course_id,
"assignments", assignment_id,
"submissions", user_id,
"comments/files"
)
my.upload_file(
url, local_file_path,
parent_folder_id, parent_folder_path, on_duplicate, verbose=verbose
)
}my.upload_submission_comment_file(
course_id, assignment_id, user_id, local_file_path="./tmp/Test03.txt"
) -> resp.upload_submission_comment_fileRows: 1
Columns: 18
$ id <int> 2973100
$ uuid <chr> "xIbUWS9hpUrQhwpLTXWkUWpkxocDYCQVYoJ4M7oq"
$ display_name <chr> "Test03.txt"
$ filename <chr> "Test03.txt"
$ upload_status <chr> "success"
$ `content-type` <chr> "text/plain"
$ url <chr> "https://canvas.qut.edu.au/files/2973100/download…
$ size <int> 59
$ created_at <chr> "2023-10-02T22:32:46Z"
$ updated_at <chr> "2023-10-02T22:32:46Z"
$ locked <lgl> FALSE
$ hidden <lgl> FALSE
$ hidden_for_user <lgl> FALSE
$ modified_at <chr> "2023-10-02T22:32:46Z"
$ mime_class <chr> "text"
$ category <chr> "uncategorized"
$ locked_for_user <lgl> FALSE
$ canvadoc_session_url <chr> "/api/v1/canvadoc_session?blob=%7B%22user_id%22:2…
Wooohooo! Now for the trifecta!
file_ids <- c(resp.upload_submission_comment_file$id)
comment_str <- paste0("A comment made at ", now())
my.comment_submission(course_id, assignment_id, user_id, comment_str, file_ids) -> resp.comment_submissionList of 10
$ url : chr "https://canvas.qut.edu.au/api/v1/courses/10245/assignments/152713/submissions/74948"
$ status_code: int 200
$ headers :List of 29
..$ date : chr "Mon, 02 Oct 2023 22:32:47 GMT"
..$ content-type : chr "application/json; charset=utf-8"
..$ transfer-encoding : chr "chunked"
..$ connection : chr "keep-alive"
..$ server : chr "Apache"
..$ x-session-id : chr "e4707634b9a898dd7ae2e997bd86dd97"
..$ x-request-context-id : chr "ef7d519a-873b-4370-a363-4900bd35ed48"
..$ vary : chr "Accept-Encoding"
..$ content-encoding : chr "gzip"
..$ x-rate-limit-remaining : chr "700.0"
..$ x-canvas-meta : chr "q=5587;at=218620000000130302;dk=170000000000016;a=1;g=D3iF4fW3Awyu6Nr3suRIUCTO1oQn1fxQRXPADwf6;s=21862;c=cluste"| __truncated__
..$ content-security-policy : chr "frame-ancestors 'self' canvas.qut.edu.au qut-prod.instructure.com qut-prod.staging.instructure.com qut-prod.bet"| __truncated__
..$ x-request-cost : chr "0.5013356310054614"
..$ cache-control : chr "max-age=0, private, must-revalidate"
..$ strict-transport-security : chr "max-age=63072000"
..$ referrer-policy : chr "no-referrer-when-downgrade"
..$ x-permitted-cross-domain-policies: chr "none"
..$ x-xss-protection : chr "1; mode=block"
..$ x-canvas-user-id : chr "218620000000001626"
..$ x-download-options : chr "noopen"
..$ x-runtime : chr "0.564735"
..$ x-content-type-options : chr "nosniff"
..$ set-cookie : chr "_csrf_token=rpaOLL%2F8KuSmVbrUS4bMOkT05bzaYKdWaHKQxqjuJt7do8ZF07hzipIP%2FbUEy6JvI5a%2F87ZPxWAOM9uq24hCkQ%3D%3D; path=/; secure"
..$ set-cookie : chr "log_session_id=e4707634b9a898dd7ae2e997bd86dd97; path=/; secure; HttpOnly"
..$ x-request-processor : chr "09a0b60ea577f129e"
..$ x-a11y-ally : chr "Dana Danger Grey"
..$ etag : chr "W/\"ea4331a6e487fb61c171fdff132e536f\""
..$ status : chr "200 OK"
..$ p3p : chr "CP=\"None, see http://www.instructure.com/privacy-policy\""
..- attr(*, "class")= chr [1:2] "insensitive" "list"
$ all_headers:List of 1
..$ :List of 3
.. ..$ status : int 200
.. ..$ version: chr "HTTP/1.1"
.. ..$ headers:List of 29
.. .. ..- attr(*, "class")= chr [1:2] "insensitive" "list"
$ cookies :'data.frame': 4 obs. of 7 variables:
..$ domain : chr [1:4] "canvas.qut.edu.au" "#HttpOnly_canvas.qut.edu.au" "#HttpOnly_canvas.qut.edu.au" "#HttpOnly_canvas.qut.edu.au"
..$ flag : logi [1:4] FALSE FALSE FALSE FALSE
..$ path : chr [1:4] "/" "/" "/" "/"
..$ secure : logi [1:4] TRUE TRUE TRUE TRUE
..$ expiration: POSIXct[1:4], format: "Inf" "Inf" ...
..$ name : chr [1:4] "_csrf_token" "log_session_id" "_legacy_normandy_session" "canvas_session"
..$ value : chr [1:4] "rpaOLL%2F8KuSmVbrUS4bMOkT05bzaYKdWaHKQxqjuJt7do8ZF07hzipIP%2FbUEy6JvI5a%2F87ZPxWAOM9uq24hCkQ%3D%3D" "e4707634b9a898dd7ae2e997bd86dd97" "E1pKzaTVd_7F9ugHPR1mng.I-GJuArngIDZzQA1b1hJmqSwqDoWSmw4NEw_19ZCVBaDZuGWJqAxPgRyqKoEspbm85zXVBgbD5oCxuAuZtswDnGq"| __truncated__ "E1pKzaTVd_7F9ugHPR1mng.I-GJuArngIDZzQA1b1hJmqSwqDoWSmw4NEw_19ZCVBaDZuGWJqAxPgRyqKoEspbm85zXVBgbD5oCxuAuZtswDnGq"| __truncated__
$ content : raw [1:29735] 7b 22 69 64 ...
$ date : POSIXct[1:1], format: "2023-10-02 22:32:47"
$ times : Named num [1:6] 0 0.000125 0.000125 0.002079 0.002107 ...
..- attr(*, "names")= chr [1:6] "redirect" "namelookup" "connect" "pretransfer" ...
$ request :List of 7
..$ method : chr "PUT"
..$ url : chr "https://canvas.qut.edu.au/api/v1/courses/10245/assignments/152713/submissions/74948"
..$ headers : Named chr [1:2] "application/json, text/xml, application/xml, */*" "Bearer 21862~XT8vfBT4bypRLsuH7NeS0iusoVClEOm4gjo21i4TDVb8nUpiPaF2cE2yhAdcV92S"
.. ..- attr(*, "names")= chr [1:2] "Accept" "Authorization"
..$ fields :List of 6
.. ..$ access_token : chr "21862~XT8vfBT4bypRLsuH7NeS0iusoVClEOm4gjo21i4TDVb8nUpiPaF2cE2yhAdcV92S"
.. ..$ comment[text_comment] : chr "A comment made at 2023-10-03 08:32:44.083693"
.. ..$ comment[group_comment]: chr "TRUE"
.. ..$ include[visibility] : chr "TRUE"
.. ..$ per_page : chr "100"
.. ..$ comment[file_ids][] : chr "2973100"
..$ options :List of 2
.. ..$ useragent : chr "rcanvas - https://github.com/daranzolin/rcanvas"
.. ..$ customrequest: chr "PUT"
..$ auth_token: NULL
..$ output : list()
.. ..- attr(*, "class")= chr [1:2] "write_memory" "write_function"
..- attr(*, "class")= chr "request"
$ handle :Class 'curl_handle' <externalptr>
- attr(*, "class")= chr "response"
That seems to have worked BUT
my.get_submission(course_id, assignment_id, user_id) -> resp.get_submission
last.attempt <- resp.get_submission$attemptSo, just need to fix the following
attempt argument.my.comment_submission <- function(
course_id, assignment_id, user_id, attempt=1, comment_str,
file_ids = NULL, to_group = TRUE, visible = TRUE,
verbose = FALSE) {
url <- rcanvas:::make_canvas_url(
"courses", course_id,
"assignments", assignment_id,
"submissions", user_id)
args <- list(
access_token = rcanvas:::check_token(),
`comment[text_comment]` = comment_str,
`comment[attempt]` = attempt,
`comment[group_comment]` = to_group,
`include[visibility]` = visible,
per_page = 100)
if(!is.null(file_ids))
args <- c(
args, rcanvas:::iter_args_list(file_ids, "comment[file_ids][]")
)
invisible(my.canvas_query(url, args, "PUT", verbose = verbose))
}file_ids <- c(resp.upload_submission_comment_file$id)
comment_str <- paste0("A comment made at ", now())
my.comment_submission(
course_id, assignment_id, user_id, last.attempt,
comment_str, file_ids) -> resp.comment_submissionSuccess!!! Time to sleep!
The GraphiQL interface to Canvas looks pretty useful. Try this:
…and you should see something like
{
"data": {
"allCourses": [
{
"_id": "4089",
"id": "Q291cnNlLTQwODk=",
"name": "DSB100_21se2 Fundamentals of Data Science"
},
{
"_id": "12878",
"id": "Q291cnNlLTEyODc4",
"name": "DSB100_22se2 Fundamentals of Data Science"
},
...
]
}
}I haven’t figured out the syntax for selecting specific objects but
returns
{
"data": {
"legacyNode": {
"_id": "14567",
"id": "Q291cnNlLTE0NTY3",
"name": "DSB100_23se2 Fundamentals of Data Science"
}
}
}Alas! No time to spend on this now… must press on!
11. Comment on submission
Get submission comments
my.get_submission_comments()…is a helper function to retrieve the comments from a specific user’s submission. It is based on
my.get_submission()my.display_comments()… is a helper function to display comment information from the output of
my.get_submission_comments()Add a comment to a submission
Let’s display the comment information of the current submission, put the new comment in and see what happens: