Setup

Aims

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

  • upload individualised assessment marks, comments and files of feedback
  • upload individualised files for students to download
  • create folders to upload files into
  • send individualised “email” messages
  • download gradebook information to check that marks are correct.

References I have found useful

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

devtools::install_github("daranzolin/rcanvas")

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.

1. Get the keys to QUT’s Canvas

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:

set_canvas_domain("https://canvas.qut.edu.au")

At one point, R started producing this error on startup:

Error in exists(cacheKey, where = .rs.WorkingDataEnv, inherits = FALSE) : 
  invalid first argument

The solution was to install the latest version of RStudio.

2. Get info about my courses

It was reassuring to see rcanvas::get_course_list() work first time

get_course_list() |>  
  select(id, course_code, name, ) |> 
  kbl() |> 
  kable_minimal(full_width = F)
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

3. Get help with Canvas ids

Canvas uses two different kinds identifiers to specify different pieces of data::

  • numeric (long integer) identifiers, denoted by _id, a.k.a. legacy or traditional identifiers
    • these are the ones used in the REST interface
  • alphanumeric identifiers, denoted by id

Since the numeric identifiers are all over the place in rcanvas, I wrote some helper functions to work with them.

Helper: 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"):

list_courses("DSB")  |>  kbl() |>   kable_minimal(full_width = F)
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

Helper: 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

get_course_id_of()
 [1]  4089 12878 14567  2792 14584  2796 14585 14587 14824  2927 14843  6034
[13]  5821  5824 12674  2929 14616 14635   350   123 10245 10375
(get_course_id_of("DSB100_23se2") -> DSB100_23se2)
[1] 14567
(get_course_id_of("Sandpit")      -> Sandpit)
[1] 10245

4. Get info about my folders

Helper functions

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")
}
get_course_folders(Sandpit) |> glimpse()
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))
}
list_course_folders(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

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)
  }
}
get_folder_id_of(Sandpit)

No names, no return value.

get_folder_id_of(Sandpit, "course files")
[1] 176869
get_folder_id_of(Sandpit, name="course files")
[1] 176869
get_folder_id_of(Sandpit, full_name="course files/Icons")
[1] 603765

5. Create folders on Canvas

Fix rcanvas issues

rcanvas::create_course_folder()

  • The call to 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"))
}

Experiments!

List existing Sandpit folders

Here’s the initial set of folders in my Sandpit:

list_course_folders(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

Make new folders

This is the first section of code that changes anything on Canvas.

  • It is important to keep an eye on your sandpit: https://canvas.qut.edu.au/files
    • This is best run chunk by chunk which is why I have set eval=FALSE
  • Note that, if called with parent_folder_id == NULL
    • Canvas will create the named folder within /course files/unfiled/
    • Here, the root directory refers to the course file system of a specified course_id

Here’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/
    NOT
  • /course files/tmp/tmp/ as the message suggests…
my.create_course_folder(
  course_id        = Sandpit,   
  name             = "tmp/tmp",
  parent_folder_id = get_folder_id_of(Sandpit, "course files")
)

Don’t forget to tidy up!

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.

6. List files in specific folders

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.,
get_course_items(Sandpit, "files") |> as_tibble()
# 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 rows

Figure out get_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 Canvas
      • paginate() then ensures that the (potentially very long) response is compiled

So 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.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()
}

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)
}

Experiments!

I’ve set eval=FALSE to ensure that my API key is not revealed when I knit this.

my.get_course_items(Sandpit, "files", verbose=TRUE) |> invisible()
> [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

  • The endpoint is https://canvas.qut.edu.au/api/v1/courses/10245/files
    • See List files
      • GET /api/v1/courses/:course_id/files
    • But what we want is to specify a folder, not a course, i.e.,
      • GET /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:

(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) |>
  glimpse()

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!

get_folder_id_of(Sandpit, full_name="course files/tmp") |>
  my.get_folder_files() |>
  glimpse()

Don’t forget to tidy up!

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.

7. Uploading files with rcanvas

Issues with rcanvas::upload_course_file()

rcanvas::upload_course_file() has some issues which we demonstrate here by:

  • uploading this file (CanvasAPI-BabySteps.Rmd) to course files/tmp
  • uploading this file and its path (./CanvasAPI-BabySteps.Rmd) to course files/tmp
upload_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.,

basename("./CanvasAPI-BabySteps.Rmd")

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 calls
    • rcanvas::upload_content()
  • So I’m going to instrument versions of these to try to figure out the call again

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
  )
}

Don’t forget to tidy up!

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.

Experiments!

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
  )
}

Check that the upload has succeeded!

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

cat("Test file 2 from CanvasAPI-BabySteps.Rmd\n", file = "./tmp/below/test2.txt")
my.upload_course_file(
  Sandpit, "./tmp/below/test2.txt",
  parent_folder_path = "/tmp" #, verbose=TRUE
) -> resp
File test2.txt uploaded

Let’s check resp:

glimpse(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

  • This is actually a value I recorded earlier (it’s not from the return value of 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 again

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(
    url, local_file_path,
    parent_folder_id, parent_folder_path, on_duplicate, verbose=verbose
  )
}

Experiments!

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!!!

8. Get assignment information

rcanvas::get_assignment_list() works straight out of the box with the one test assignment I created in my sandpit

glimpse(get_assignment_list(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

get_assignment_list(Sandpit) |>  
  select(id, name, due_at) |>
  kbl() |> kable_minimal(full_width = F)
id name due_at
152713 Test Assignment NA

9. Get submission information

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

10. Score submission

Get the submission to grade

my.display_grade()

… is a helper function to display grade information from the output of get_submissions()

my.display_grade <- function(submission){
  submission |>   
    select(
      id, course_id, assignment_id, user_id,
      grade, entered_grade, 
      score, entered_score
    ) |>
    kbl() |> 
    kable_minimal(full_width = F)
}

rcanvas::get_submissions()

Here’s some information about the one test submission to my one test assignment

get_submissions(Sandpit, "assignments", TestAssignment) |> my.display_grade()
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()

get_submissions(Sandpit, "assignments", TestAssignment) |> names()
 [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"                       
get_submission_single(Sandpit, "assignments", TestAssignment, Student001) |> names()
 [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.

  • I have a feeling that rcanvas refers to these as arguments
  • I’m going to see what happwns if I make this call with no arguments first:
resp <- my.process_response(url, args=NULL, verbose=FALSE)
glimpse(resp)
Rows: 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 <- c("submission_comments")
(args   <- rcanvas:::iter_args_list(include, "include[]"))
$`include[]`
[1] "submission_comments"

Here’s the call:

resp <- my.process_response(url, args, verbose=FALSE)
glimpse(resp)
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:

glimpse(resp$submission_comments)
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.

  • I asked for both submission_comments and user information to be returned with the submissions
  • That happened so I got
    • data about 1 submission_id in which was nested
      • data about 3 comment_ids on that submission
      • data about 1 user_id for that submission
  • …and that does not flatten nicely into a rectangular data frame!

Time to move on! I think I will have to content myself with just

  • adding comments (with rcanvas::comment_submission())
  • adding grades (with rcanvas::grade_submission()… or should that be score?)
  • adding files (somehow!)

rcanvas::grade_submission() Take 1

The code for rcanvas::grade_submission()

  • sets the argument submission[posted_grade]` = grade on line 105.
  • So it looks like we need something different to set the score
  • However, the submissions API documentation explains under submission[posted_grade] that this call

Assigns a score to the submission, updating both the score and grade fields 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()
  • I’m going to try to emulate 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 2

Check the current grade and score of the submission:

submission |> select(grade, score)
# 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

  • “0” if grade is NULL or non-numeric
  • “0” is grade
  • the string value of ((the numeric value of grade) + 1), otherwise
newgrade <-
  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

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.get_submission_comments <- 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 <- c(
    list(
      access_token = rcanvas:::check_token(),
      per_page = 100
    ),
    rcanvas:::iter_args_list("submission_comments", "include[]")
  )
  
  my.process_response(url, args, verbose=verbose) |>
    pull(submission_comments)
}

my.display_comments()

… is a helper function to display comment information from the output of my.get_submission_comments()

my.display_comments <- function(comments){
  comments |>   
    select(id, created_at, author_id, author_name, comment) |>
    kbl() |> 
    kable_minimal(full_width = F)
}

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:

# Display the current submission's comment information
my.get_submission_comments(course_id, assignment_id, user_id) |> 
  tail(5) |>  my.display_comments() |> kable_styling(font_size = 11)
id created_at author_id author_name comment
21 390100 2023-10-02T14:38:31Z 1626 David Lovell A comment made at 2023-10-03 00:38:31.950135
22 390103 2023-10-02T14:46:19Z 1626 David Lovell A comment made at 2023-10-03 00:46:20.300177
23 390104 2023-10-02T14:46:22Z 1626 David Lovell A comment made at 2023-10-03 00:46:23.163047
24 390105 2023-10-02T14:46:23Z 1626 David Lovell A comment made at 2023-10-03 00:46:24.309069
25 390106 2023-10-02T14:46:24Z 1626 David Lovell A comment made at 2023-10-03 00:46:24.992609
newcomment <- paste0("A comment made at ", now())
# Update the submission's comment information on Canvas
comment_submission( course_id, assignment_id, user_id, newcomment)
# Display the updated comment information
my.get_submission_comments(course_id, assignment_id, user_id) |> 
  tail(5) |>  my.display_comments() |> kable_styling(font_size = 11)
id created_at author_id author_name comment
22 390103 2023-10-02T14:46:19Z 1626 David Lovell A comment made at 2023-10-03 00:46:20.300177
23 390104 2023-10-02T14:46:22Z 1626 David Lovell A comment made at 2023-10-03 00:46:23.163047
24 390105 2023-10-02T14:46:23Z 1626 David Lovell A comment made at 2023-10-03 00:46:24.309069
25 390106 2023-10-02T14:46:24Z 1626 David Lovell A comment made at 2023-10-03 00:46:24.992609
26 390268 2023-10-02T22:32:41Z 1626 David Lovell A comment made at 2023-10-03 08:32:39.049829

12. Attach files to comments

It looks like the following API calls are relevant::

Get submission files

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/comments
  • GET /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/comments
  • submissions/:user_id/
  • Returns a Submission

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

  • I’m not sure if this supports the attachment of more than one file to a comment. Arg!

It looks like the only way to tell if there is a file attached is via the web interface.

  • When I go to my Sandpit, I have to enter “Speed Grader” to see comments linked to a particular submission.
    • It looks like we can upload one file attachment with one comment on a submission
    • Students then have to go to their submissions and click on View Feedback.
  • Also, it’s not clear where the files attached to comments live on Canvas.
    • They are not in /course files
    • Here are the first 10 comments I can see at the time of writing:
# 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.
  • If I look at the links to the files on the attachments I have created I see see
    • https://canvas.qut.edu.au/courses/10245/assignments/152713/submissions/74948?comment_id=389383&download=2972077
    • https://canvas.qut.edu.au/courses/10245/assignments/152713/submissions/74948?comment_id=389386&download=2972081
  • So, in those URLs I can see the
    • course_id: 10245
    • assignment_id: 152713
    • author_id: 74948 (“Test student”)
      • …even though I authored the comment (my author_id is 1626; my author_name is “David Lovell”)
    • comment_ids: 389383, 389386
  • What I don’t recognise are the numbers after download=, i.e., 2972077, 2972081
  • I’m going to try to retrieve these files using the Get file API
curl '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
}
  • OK. So the file is somewhere in Canvas… I don’t know where.
    • The next thingk for me to try is to upload a comment with a file attachment
    • I think I can send a file_id of a file in my /course files folder

Create and upload a file as an attachment to a comment

cat("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")
my.upload_course_file(course_id,"./tmp/Test03.txt", parent_folder_path = "/tmp")
# 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>
my.upload_course_file(course_id,"./tmp/Test04.txt", parent_folder_path = "/tmp") 
# 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
      • Add a textual comment to the submission.
    • comment[file_ids][] integer
      • Attach files to this comment that were previously uploaded using the Submission Comment API’s files action

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) -> resp

Great! 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)) -> resp

Great! 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) -> resp
Error in my.canvas_query(url, args, "PUT", verbose = verbose) : 
  Forbidden (HTTP 403).

HTTP 403 means the server understands the query but refuses to do it. Dang!

  • But if I go back to the Grade or comment on a submission, it explains that
    • comment[file_ids][] integer
      • Attach files to this comment that were previously uploaded using the Submission Comment API’s files action

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_file
glimpse(resp.upload_submission_comment_file)
Rows: 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_submission
glimpse(resp.comment_submission)
List 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

  • When I check on Canvas, I see nothing until…
    • I look at the first uploaded attempt which has been steadily accumulating comments and file uploads, etc.
    • There is still more to do… i.e., commenting on the latest attempt
my.get_submission(course_id, assignment_id, user_id) -> resp.get_submission
last.attempt <- resp.get_submission$attempt

So, just need to fix the following

  • Which will need an 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_submission

Success!!! Time to sleep!

A: GraphiQL interface to Canvas

The GraphiQL interface to Canvas looks pretty useful. Try this:

  1. visit https://canvas.qut.edu.au/graphiql
  2. Enter the following into the query window, then hit ▶
query MyCourses {
  allCourses {
    _id
    id
    name
  }
}

…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

query MyQuery {
  legacyNode(_id: "14567", type: Course) {
    ... on Course {
      _id
      id
      name
    }
  }
}

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!