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, 698585
$ 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-08-23T03:29:49Z", "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, 30
$ locked           <lgl> FALSE, 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, 2
$ 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 698585            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 698585            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:
get_course_items(Sandpit, "files") |> as_tibble()
# A tibble: 382 × 24
        id uuid     folder_id display_name filename upload_status `content-type`
     <int> <chr>        <int> <chr>        <chr>    <chr>         <chr>         
 1 2967461 8yFTrlw…    698585 ./tmp/below… .%2Ftmp… success       text/plain    
 2 2708042 iDZ8Rws…    603766 alarm-excla… alarm-e… success       image/png     
 3 2708036 UN6utaU…    603766 alarm-excla… alarm-e… success       image/png     
 4 2708037 9y6ZwQ3…    603766 alarm-excla… alarm-e… success       image/png     
 5 2708038 GQBhZcL…    603766 alarm-excla… alarm-e… success       image/png     
 6 2708043 VVDgpBM…    603766 anchor_009F… anchor_… success       image/png     
 7 2708044 ckaLG0B…    603766 anchor_012A… anchor_… success       image/png     
 8 2708046 873yBp4…    603766 anchor_C702… anchor_… success       image/png     
 9 2708045 V91aJF7…    603766 anchor_EFCB… anchor_… success       image/png     
10 2708111 gW5w3xB…    603766 arrow-down-… arrow-d… success       image/png     
# ℹ 372 more rows
# ℹ 17 more variables: url <chr>, size <int>, created_at <chr>,
#   updated_at <chr>, 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>

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

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
)

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-09-29T22:12:10Z"
$ 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> FALSE
$ 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> TRUE
$ html_url                             <chr> "https://canvas.qut.edu.au/course…
$ has_overrides                        <lgl> FALSE
$ needs_grading_count                  <int> 1
$ 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 gives a lovely

get_assignment_list(DSB100_23se2) |> 
  select(id, name, due_at) |> kbl() |> kable_minimal(full_width = F)
id name due_at
145929 DSB100: Assessment 1 due 2023-09-15T13:59:59Z
149838 DSB100: Assessment 2 due 2023-10-09T13:59:59Z
149839 DSB100: Assessment 3 due 2023-11-04T03:00:00Z
glimpse(get_submissions(Sandpit, "assignments", 152713))
Rows: 1
Columns: 31
$ id                               <int> 4627314
$ body                             <chr> "<link rel=\"stylesheet\" href=\"http…
$ url                              <lgl> NA
$ grade                            <lgl> NA
$ score                            <lgl> NA
$ 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> "submitted"
$ grade_matches_current_submission <lgl> TRUE
$ graded_at                        <lgl> NA
$ grader_id                        <lgl> NA
$ attempt                          <int> 2
$ cached_due_date                  <lgl> NA
$ excused                          <lgl> NA
$ late_policy_status               <lgl> NA
$ points_deducted                  <lgl> NA
$ grading_period_id                <lgl> NA
$ extra_attempts                   <lgl> NA
$ posted_at                        <lgl> NA
$ 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                    <lgl> NA
$ entered_score                    <lgl> NA
$ preview_url                      <chr> "https://canvas.qut.edu.au/courses/10…
$ course_id                        <int> 10245

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!