diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e9f4dd9..5a28b93 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -36,26 +36,41 @@ Git and GitHub. For more general info about contributing to `ruODK`, see the [Resources](#resources) at the end of this document. +### Naming conventions +ruODK names functions after ODK Central endpoints. If there are aliases, such as +"Dataset" and "Entity List", choose the alias that is shown to Central users +(here, choose "Entity List") over internally used terms. + +Function names combine the object name (`project`, `form`, `submission`, +`attachment`, `entitylist`, `entity`, etc.) with the action (`list`, `detail`, +`patch`) as snake case, e.g. `project_list()`. +In case of any uncertainty, discussion is welcome. + +In contrast, `pyODK` uses a class based approach with the pluralised object name +separated from the action `client.entity_lists.list()`. + +Documentation should capitalise ODK Central object names: Project, Form, +Submission, Entity. + ### Prerequisites -To test the package, you will need valid credentials for the ODK Central instance -used as a test server. -Create an [account request issue](https://github.com/ropensci/ruODK/issues/new/choose). +To test the package, you will need valid credentials for an existing ODK Central +instance to be used as a test server. Before you do a pull request, you should always file an issue and make sure the maintainers agree that it is a problem, and is happy with your basic proposal for fixing it. If you have found a bug, follow the issue template to create a minimal -[reprex](https://www.tidyverse.org/help/#reprex). +[reprex](https://www.tidyverse.org/help/#reprex) if you can do so without +revealing sensitive information. Never include credentials in your reprex. ### Checklists Some changes have intricate internal and external dependencies, which are easy to miss and break. These checklists aim to avoid these pitfalls. -Test and update reverse dependencies (wastdr, etlTurtleNesting, etc.). - #### Adding a dependency * Update DESCRIPTION -* Update GH Actions install workflows - do R package deps have system deps? Can GHA install them in all environments? +* Update GH Actions install workflows - do R package deps have system deps? + Can GHA install them in all environments? * Update Dockerfile * Update binder install.R * Update installation instructions diff --git a/DESCRIPTION b/DESCRIPTION index aa48440..cbc80ff 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -1,7 +1,7 @@ Type: Package Package: ruODK Title: An R Client for the ODK Central API -Version: 1.4.2 +Version: 1.4.9.9002 Authors@R: c(person(given = c("Florian", "W."), family = "Mayer", diff --git a/NAMESPACE b/NAMESPACE index 59b8e4c..4d3257e 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -11,6 +11,10 @@ export(encryption_key_list) export(enexpr) export(enquo) export(ensym) +export(entitylist_detail) +export(entitylist_download) +export(entitylist_list) +export(entitylist_update) export(expr) export(exprs) export(form_detail) diff --git a/NEWS.md b/NEWS.md index ec465a1..adcfaf6 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,3 +1,7 @@ +# ruODK 1.5.0 +## Major changes +* Support Entities and Entity Lists (Datasets) (#152) + # ruODK 1.4.2 This release migrates the `ruODK` test suite to a new test server `ruodk.getodk.cloud` which was generously sponsored by GetODK. @@ -16,9 +20,9 @@ This release fixes a few compatibility issues and bumps dependencies to R (4.1) and imported/suggested packages. Upgrade carefully and revert to 1.3.12 if things go awry. -* Update to new tidyselect syntax: Using vectors of names to select makes - tidyselect complain (WARN, soon ERROR). We wrap all programmatic selections of - variable names in `dplyr::all_of()` where we expect a single variable to be +* Update to new `tidyselect` syntax: Using vectors of names to select makes + `tidyselect` complain (WARN, soon ERROR). We wrap all programmatic selections + of variable names in `dplyr::all_of()` where we expect a single variable to be selected, and `dplyr::any_of()` where we select using fuzzy matching (e.g. `dplyr::starts_with()`). (#146) * Make `ruODK::form_list()` robust against `reviewState` missing from outdated diff --git a/R/entitylist_detail.R b/R/entitylist_detail.R new file mode 100644 index 0000000..78a770e --- /dev/null +++ b/R/entitylist_detail.R @@ -0,0 +1,93 @@ +#' Show Entity List details. +#' +#' `r lifecycle::badge("maturing")` +#' +#' An Entity List is a named collection of Entities that have the same +#' properties. +#' Entity List can be linked to Forms as Attachments. +#' This will make it available to clients as an automatically-updating CSV. +#' +#' This function is supported from ODK Central v2022.3 and will warn if the +#' given odkc_version is lower. +#' +#' @template param-pid +#' @template param-did +#' @template param-url +#' @template param-auth +#' @template param-retries +#' @template param-odkcv +#' @template param-orders +#' @template param-tz +#' @return A list of lists following the exact format and naming of the API +#' response. Since this nested list is so deeply nested and irregularly shaped +#' it is not trivial to rectangle the result into a tibble. +# nolint start +#' @seealso \url{ https://docs.getodk.org/central-api-dataset-management/#datasets} +# nolint end +#' @family entity-management +#' @export +#' @examples +#' \dontrun{ +#' # See vignette("setup") for setup and authentication options +#' # ruODK::ru_setup(svc = "....svc", un = "me@email.com", pw = "...") +#' +#' ds <- entitylist_list(pid = get_default_pid()) +#' ds1 <- entitylist_detail(pid = get_default_pid(), did = ds$name[1]) +#' +#' ds1 |> listviewer::jsonedit() +#' ds1$linkedForms |> +#' purrr::list_transpose() |> +#' tibble::as_tibble() +#' ds1$sourceForms |> +#' purrr::list_transpose() |> +#' tibble::as_tibble() +#' ds1$properties |> +#' purrr::list_transpose() |> +#' tibble::as_tibble() +#' } +entitylist_detail <- function(pid = get_default_pid(), + did = NULL, + url = get_default_url(), + un = get_default_un(), + pw = get_default_pw(), + retries = get_retries(), + odkc_version = get_default_odkc_version(), + orders = c( + "YmdHMS", + "YmdHMSz", + "Ymd HMS", + "Ymd HMSz", + "Ymd", + "ymd" + ), + tz = get_default_tz()) { + yell_if_missing(url, un, pw, pid = pid) + + if (is.null(did)) { + ru_msg_abort("entitylist_detail requires the Entity List name as 'did=\"name\"'.") + } + + if (odkc_version |> semver_lt("2022.3")) { + ru_msg_warn("entitylist_detail is supported from v2022.3") + } + + ds <- httr::RETRY( + "GET", + httr::modify_url(url, + path = glue::glue( + "v1/projects/{pid}/datasets/", + "{URLencode(did, reserved = TRUE)}" + ) + ), + httr::add_headers( + "Accept" = "application/json", + "X-Extended-Metadata" = "true" + ), + httr::authenticate(un, pw), + times = retries + ) |> + yell_if_error(url, un, pw) |> + httr::content(encoding = "utf-8") +} + +# usethis::use_test("entitylist_detail") # nolint diff --git a/R/entitylist_download.R b/R/entitylist_download.R new file mode 100644 index 0000000..f1b4da6 --- /dev/null +++ b/R/entitylist_download.R @@ -0,0 +1,213 @@ +#' Download an Entity List as CSV. +#' +#' `r lifecycle::badge("maturing")` +#' +#' The downloaded CSV file is named after the entity list name. +#' The download location defaults to the current workdir, but can be modified +#' to a different folder path which will be created if it doesn't exist. +#' +#' An Entity List is a named collection of Entities that have the same +#' properties. +#' Entity List can be linked to Forms as Attachments. +#' This will make it available to clients as an automatically-updating CSV. +#' +#' Entity Lists can be used as Attachments in other Forms, but they can also be +#' downloaded directly as a CSV file. +#' The CSV format closely matches the OData Dataset (Entity List) Service +#' format, with columns for system properties such as `__id` (the Entity UUID), +#' `__createdAt`, `__creatorName`, etc., the Entity Label label, and the +#' Dataset (Entity List )/Entity Properties themselves. +#' If any Property for an given Entity is blank (e.g. it was not captured by +#' that Form or was left blank), that field of the CSV is blank. +#' +#' The ODK Central `$filter` querystring parameter can be used to filter on +#' system-level properties, similar to how filtering in the OData Dataset +#' (Entity List) Service works. +#' Of the [OData filter specs](https://docs.oasis-open.org/odata/odata/v4.01/odata-v4.01-part1-protocol.html#_Toc31358948) +#' ODK Central implements a [growing set of features +#' ](https://docs.getodk.org/central-api-odata-endpoints/#data-document). +#' `ruODK` provides the parameter `filter` (str) which, if set, will be passed +#' on to the ODK Central endpoint as is. +#' +#' The ODK Central endpoint supports the [`ETag` header +#' ](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/ETag), which can +#' be used to avoid downloading the same content more than once. +#' When an API consumer calls this endpoint, the endpoint returns a value in +#' the `ETag` header. +#' If you pass that value in the [`If-None-Match` header +#' ](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/If-None-Match) +#' of a subsequent request, +#' then if the Entity List has not been changed since the previous request, +#' you will receive 304 Not Modified response; otherwise you'll get the new +#' data. +#' `ruODK` provides the parameter `etag` which can be set from the output of +#' a previous call to `entitylist_download()`. `ruODK` strips the `W/\"` and +#' `\"` from the returned etag and expects the stripped etag as parameter. +#' +#' @template param-pid +#' @template param-did +#' @template param-url +#' @template param-auth +#' @param local_dir The local folder to save the downloaded files to, +#' default: \code{here::here}. +#' If the folder does not exist it will be created. +#' @param etag (str) The etag value from a previous call to +#' `entitylist_download()`. The value must be stripped of the `W/\"` and `\"`, +#' which is the format of the etag returned by `entitylist_download()`. +#' If provided, only new entities will be returned. +#' If the same `local_dir` is chosen and `overwrite` is set to `TRUE`, +#' the downloaded CSV will also be overwritte, losing the Entities downloaded +#' earlier. +#' Default: NULL (no filtering, all entities returned). +#' @param filter (str) A valid filter string. +#' Default: NULL (no filtering, all entities returned). +#' @param overwrite Whether to overwrite previously downloaded file, +#' default: FALSE +#' @template param-retries +#' @template param-odkcv +#' @template param-orders +#' @template param-tz +#' @template param-verbose +#' @return A list of four items: +#' - entities (tbl_df) The Entity List as tibble +#' - http_status (int) The HTTP status code of the response. +#' 200 if OK, 304 if a given etag finds no new entities created. +#' - etag (str) The ETag to use in subsequent calls to `entitylist_download()` +#' - downloaded_to (fs_path) The path to the downloaded CSV file +#' - downloaded_on (POSIXct) The time of download in the local timezome +# nolint start +#' @seealso \url{https://docs.getodk.org/central-api-dataset-management/#datasets} +# nolint end +#' @family entity-management +#' @export +#' @examples +#' \dontrun{ +#' # See vignette("setup") for setup and authentication options +#' # ruODK::ru_setup(svc = "....svc", un = "me@email.com", pw = "...") +#' +#' ds <- entitylist_list(pid = get_default_pid()) +#' ds1 <- entitylist_download(pid = get_default_pid(), did = ds$name[1]) +#' # ds1$entities +#' # ds1$etag +#' # ds1$downloaded_to +#' # ds1$downloaded_on +#' +#' ds2 <- entitylist_download( +#' pid = get_default_pid(), +#' did = ds$name[1], +#' etag = ds1$etag +#' ) +#' # ds2$http_status == 304 +#' +#' newest_entity_date <- as.Date(max(ds1$entities$`__createdAt`)) +#' ds3 <- entitylist_download( +#' pid = get_default_pid(), +#' did = ds$name[1], +#' filter = glue::glue("__createdAt le {newest_entity_date}") +#' ) +#' } +entitylist_download <- function(pid = get_default_pid(), + did = NULL, + url = get_default_url(), + un = get_default_un(), + pw = get_default_pw(), + local_dir = here::here(), + filter = NULL, + etag = NULL, + overwrite = TRUE, + retries = get_retries(), + odkc_version = get_default_odkc_version(), + orders = c( + "YmdHMS", + "YmdHMSz", + "Ymd HMS", + "Ymd HMSz", + "Ymd", + "ymd" + ), + tz = get_default_tz(), + verbose = get_ru_verbose()) { + # Gatecheck params + yell_if_missing(url, un, pw, pid = pid) + + if (is.null(did)) { + ru_msg_abort( + "entitylist_download requires the Entity List name as 'did=\"name\"'." + ) + } + + # Gatecheck ODKC version + if (odkc_version |> semver_lt("2022.3")) { + ru_msg_warn("entitylist_download is supported from v2022.3") + } + + # Download file destination directory + if (!fs::dir_exists(local_dir)) { + fs::dir_create(local_dir) + } + + # Downloaded file path + pth <- fs::path(local_dir, glue::glue("{did}.csv")) + + # Emit message + if (fs::file_exists(pth)) { + if (overwrite == TRUE) { + "Overwriting previous entity list: \"{pth}\"" %>% + glue::glue() %>% + ru_msg_success(verbose = verbose) + } else { + "Keeping previous entity list: \"{pth}\"" %>% + glue::glue() %>% + ru_msg_success(verbose = verbose) + } + } else { + "Downloading entity list \"{did}\" to {pth}" %>% + glue::glue() %>% + ru_msg_success(verbose = verbose) + } + + # Headers: accept CSV, set ETag if given + headers <- c(Accept = "text/csv; charset=utf-8") + if (!is.null(etag)) { + if (odkc_version |> semver_lt("2023.3")) { + ru_msg_warn("entitylist_download ETag is supported from v2023.3") + } + headers <- c(headers, c("If-None-Match" = etag)) + } + + # Query: filter + query <- NULL + if (!is.null(filter)) { + query <- list("$filter" = utils::URLencode(filter, reserved = TRUE)) + } + + res <- httr::RETRY( + "GET", + httr::modify_url( + url, + path = glue::glue( + "v1/projects/{pid}/datasets/", + "{utils::URLencode(did, reserved = TRUE)}/entities.csv" + ), + query = query + ), + httr::add_headers(.headers = headers), + httr::authenticate(un, pw), + httr::write_disk(pth, overwrite = overwrite), + times = retries + ) + # yell_if_error(url, un, pw) # allow HTTP 304 for no new submissions + + list( + entities = httr::content(res, encoding = "utf-8"), + etag = res$headers$etag |> + stringr::str_remove_all(stringr::fixed("W/\"")) |> + stringr::str_remove_all(stringr::fixed("\"")), + http_status = res$status_code, + downloaded_to = pth, + downloaded_on = isodt_to_local(res$date, orders = orders, tz = tz) + ) +} + + +# usethis::use_test("entitylist_download") # nolint diff --git a/R/entitylist_list.R b/R/entitylist_list.R new file mode 100644 index 0000000..66dc833 --- /dev/null +++ b/R/entitylist_list.R @@ -0,0 +1,86 @@ +#' List all Entity Lists of one Project. +#' +#' `r lifecycle::badge("maturing")` +#' +#' While the API endpoint will return all Entity Lists for one Project, +#' \code{\link{entitylist_list}} will fail with incorrect or missing +#' authentication. +#' +#' An Entity List is a named collection of Entities that have the same properties. +#' An Entity List can be linked to Forms as Attachments. +#' This will make it available to clients as an automatically-updating CSV. +#' +#' ODK Central calls Entity Lists internally Datasets. `ruODK` chooses the term +#' Entity Lists as it is used in the ODK Central user interface and conveys +#' its relation to Entities better than the term Dataset. +#' +#' This function is supported from ODK Central v2022.3 and will warn if the +#' given odkc_version is lower. +#' +#' @template param-pid +#' @template param-url +#' @template param-auth +#' @template param-retries +#' @template param-odkcv +#' @template param-orders +#' @template param-tz +#' @return A tibble with exactly one row for each dataset of the given project +#' as per ODK Central API docs. +#' Column names are renamed from ODK's `camelCase` to `snake_case`. +# nolint start +#' @seealso \url{ https://docs.getodk.org/central-api-dataset-management/#datasets} +# nolint end +#' @family entity-management +#' @export +#' @examples +#' \dontrun{ +#' # See vignette("setup") for setup and authentication options +#' # ruODK::ru_setup(svc = "....svc", un = "me@email.com", pw = "...") +#' +#' ds <- entitylist_list(pid = get_default_pid()) +#' +#' ds |> knitr::kable() +#' } +entitylist_list <- function(pid = get_default_pid(), + url = get_default_url(), + un = get_default_un(), + pw = get_default_pw(), + retries = get_retries(), + odkc_version = get_default_odkc_version(), + orders = c( + "YmdHMS", + "YmdHMSz", + "Ymd HMS", + "Ymd HMSz", + "Ymd", + "ymd" + ), + tz = get_default_tz()) { + yell_if_missing(url, un, pw, pid = pid) + + if (odkc_version |> semver_lt("2022.3")) { + ru_msg_warn("entitylist_list is supported from v2022.3") + } + + httr::RETRY( + "GET", + httr::modify_url(url, path = glue::glue("v1/projects/{pid}/datasets")), + httr::add_headers( + "Accept" = "application/json", + "X-Extended-Metadata" = "true" + ), + httr::authenticate(un, pw), + times = retries + ) |> + yell_if_error(url, un, pw) |> + httr::content(encoding = "utf-8") |> + purrr::list_transpose() |> + tibble::as_tibble() |> + janitor::clean_names() |> + dplyr::mutate_at( + dplyr::vars(c("created_at", "last_entity")), + ~ isodt_to_local(., orders = orders, tz = tz) + ) +} + +# usethis::use_test("entitylist_list") # nolint diff --git a/R/entitylist_update.R b/R/entitylist_update.R new file mode 100644 index 0000000..128d48c --- /dev/null +++ b/R/entitylist_update.R @@ -0,0 +1,113 @@ +#' Update Entity List details. +#' +#' `r lifecycle::badge("maturing")` +#' +#' You can only update `approvalRequired` using this endpoint. +#' The approvalRequired flag controls the Entity creation flow; +#' if it is true then the Submission must be approved before an Entity can be +#' created from it and if it is false then an Entity is created as soon as the +#' Submission is received by the ODK Central. +#' By default `approvalRequired` is false for the Entity Lists created after +#' v2023.3. Entity Lists created prior to that will have approvalRequired set to +#' true. +#' +#' An Entity List is a named collection of Entities that have the same +#' properties. +#' An Entity List can be linked to Forms as Attachments. +#' This will make it available to clients as an automatically-updating CSV. +#' +#' This function is supported from ODK Central v2022.3 and will warn if the +#' given odkc_version is lower. +#' +#' `r lifecycle::badge("maturing")` +#' +#' @template param-pid +#' @template param-did +#' @param approval_required (lgl) The value to set approvalRequired to. +#' If TRUE, a submission must be approved before an entity is created, +#' if FALSE, an entity is created as soon as the submission is received by +#' ODK Central. +#' Default: FALSE. +#' @template param-url +#' @template param-auth +#' @template param-retries +#' @template param-odkcv +#' @template param-orders +#' @template param-tz +#' @return A list of lists following the exact format and naming of the API +#' response for `entitylist_detail`. +#' Since this nested list is so deeply nested and irregularly shaped +#' it is not trivial to rectangle the result into a tibble. +# nolint start +#' @seealso \url{ https://docs.getodk.org/central-api-dataset-management/#datasets} +# nolint end +#' @family dataset-management +#' @export +#' @examples +#' \dontrun{ +#' # See vignette("setup") for setup and authentication options +#' # ruODK::ru_setup(svc = "....svc", un = "me@email.com", pw = "...") +#' +#' pid <- get_default_pid() +#' +#' ds <- entitylist_list(pid = pid) +#' +#' did <- ds$name[1] +#' +#' ds1 <- entitylist_detail(pid = pid, did = did) +#' ds1$approvalRequired # FALSE +#' +#' ds2 <- entitylist_update(pid = pid, did = did, approval_required = TRUE) +#' ds2$approvalRequired # TRUE +#' +#' ds3 <- entitylist_update(pid = pid, did = did, approval_required = FALSE) +#' ds3$approvalRequired # FALSE +#' } +entitylist_update <- function(pid = get_default_pid(), + did = NULL, + approval_required = FALSE, + url = get_default_url(), + un = get_default_un(), + pw = get_default_pw(), + retries = get_retries(), + odkc_version = get_default_odkc_version(), + orders = c( + "YmdHMS", + "YmdHMSz", + "Ymd HMS", + "Ymd HMSz", + "Ymd", + "ymd" + ), + tz = get_default_tz()) { + yell_if_missing(url, un, pw, pid = pid) + + if (is.null(did)) { + ru_msg_abort("entitylist_update requires the Entity List name as 'did=\"name\"'.") + } + + if (odkc_version |> semver_lt("2022.3")) { + ru_msg_warn("entitylist_update is supported from v2022.3") + } + + ds <- httr::RETRY( + "PATCH", + httr::modify_url(url, + path = glue::glue( + "v1/projects/{pid}/datasets/", + "{URLencode(did, reserved = TRUE)}" + ) + ), + httr::add_headers( + "Accept" = "application/json" + ), + encode = "json", + body = list(approvalRequired = approval_required), + httr::authenticate(un, pw), + times = retries + ) |> + yell_if_error(url, un, pw) |> + httr::content(encoding = "utf-8") +} + +# usethis::use_test("entitylist_update") # nolint diff --git a/R/ru_setup.R b/R/ru_setup.R index 5260a38..e2469b9 100644 --- a/R/ru_setup.R +++ b/R/ru_setup.R @@ -637,15 +637,9 @@ get_default_odkc_version <- function() { #' @export #' @examples #' get_default_odkc_version() |> semver_gt("0.8.0") -#' "2024.1.1" |> -#' parse_odkc_version() |> -#' semver_gt("2024.1.0") -#' "2024.1.1" |> -#' parse_odkc_version() |> -#' semver_gt("2024.1.1") -#' "2024.1.1" |> -#' parse_odkc_version() |> -#' semver_gt("2024.1.2") +#' "2024.1.1" |> semver_gt("2024.1.0") +#' "2024.1.1" |> semver_gt("2024.1.1") +#' "2024.1.1" |> semver_gt("2024.1.2") semver_gt <- function(sv = get_default_odkc_version(), to = "1.5.0") { base_version <- parse_odkc_version(sv, env_var = "") to_version <- parse_odkc_version(to, env_var = "") @@ -668,15 +662,9 @@ semver_gt <- function(sv = get_default_odkc_version(), to = "1.5.0") { #' @export #' @examples #' get_default_odkc_version() |> semver_lt("0.8.0") -#' "2024.1.1" |> -#' parse_odkc_version() |> -#' semver_lt("2024.1.0") -#' "2024.1.1" |> -#' parse_odkc_version() |> -#' semver_lt("2024.1.1") -#' "2024.1.1" |> -#' parse_odkc_version() |> -#' semver_lt("2024.1.2") +#' "2024.1.1" |> semver_lt("2024.1.0") +#' "2024.1.1" |> semver_lt("2024.1.1") +#' "2024.1.1" |> semver_lt("2024.1.2") semver_lt <- function(sv = get_default_odkc_version(), to = "1.5.0") { base_version <- parse_odkc_version(sv, env_var = "") to_version <- parse_odkc_version(to, env_var = "") diff --git a/R/submission_export.R b/R/submission_export.R index 8c3ca36..038a2d2 100644 --- a/R/submission_export.R +++ b/R/submission_export.R @@ -127,6 +127,10 @@ submission_export <- function(local_dir = here::here(), "{URLencode(fid, reserved = TRUE)}/submissions{url_ext}" ) + if (!fs::dir_exists(local_dir)) { + fs::dir_create(local_dir) + } + pth <- fs::path( local_dir, glue::glue("{URLencode(fid, reserved = TRUE)}{file_ext}") diff --git a/README.Rmd b/README.Rmd index f65a0d4..80e851d 100644 --- a/README.Rmd +++ b/README.Rmd @@ -223,11 +223,13 @@ See the [contributing guide](https://docs.ropensci.org/ruODK/CONTRIBUTING.html) on best practices and further readings for code contributions. ## Attribution -`ruODK` was developed, and is maintained, by Florian Mayer for the Western Australian +`ruODK` was developed by Florian Mayer for the Western Australian [Department of Biodiversity, Conservation and Attractions (DBCA)](https://www.dbca.wa.gov.au/). The development was funded both by DBCA core funding and external funds from the [North West Shelf Flatback Turtle Conservation Program](https://flatbacks.dbca.wa.gov.au/). +ruODK is maintained and extended by Florian Mayer. + To cite package `ruODK` in publications use: ```{r citation} diff --git a/_pkgdown.yml b/_pkgdown.yml index 0c8e5a6..de1a1b5 100644 --- a/_pkgdown.yml +++ b/_pkgdown.yml @@ -23,6 +23,17 @@ reference: desc: "Functions to manage projects." contents: - has_concept("project-management") +- title: Entities + desc: > + An Entity List is a named collection of Entities that have the same + properties. + In the ODK Central API and in the related ODK XForms specification, + collections of Entities are referred to as Datasets. + The term "Entity List" is used for this concept in the Central frontend UI, + user documentation, and all other text intended for end users who are not + developers. Accordingly, ruODK uses the term "entitylist_" for Entity Lists. + contents: + - has_concept("entity-management") - title: Forms desc: > Functions to manage forms. diff --git a/inst/rmarkdown/templates/odata/skeleton/skeleton.Rmd b/inst/rmarkdown/templates/odata/skeleton/skeleton.Rmd index 3866df4..e8470d3 100644 --- a/inst/rmarkdown/templates/odata/skeleton/skeleton.Rmd +++ b/inst/rmarkdown/templates/odata/skeleton/skeleton.Rmd @@ -41,12 +41,12 @@ usethis::edit_r_environ() ```{r paste_env_vars, echo=FALSE, eval=FALSE} # ODK Central web user with read-permitted role on project -ODKC_UN="my@email.com" -ODKC_PW="my-odkc-password" +ODKC_UN <- "my@email.com" +ODKC_PW <- "my-odkc-password" # CKAN user with permissions to create a dataset -CKAN_URL="https://demo.ckan.org" -CKAN_KEY="my-ckan-api-key" +CKAN_URL <- "https://demo.ckan.org" +CKAN_KEY <- "my-ckan-api-key" ``` @@ -85,7 +85,8 @@ ruODK::ru_setup( svc = paste0("https://my-odkc-instance.com/v1/projects/1/forms/form_id.svc"), tz = "Australia/Perth", # your local timezone odkc_version = "2023.5.1", # your ODKC version, only needed for older versions - verbose = TRUE) + verbose = TRUE +) loc <- fs::path("media") fs::dir_create(loc) @@ -107,7 +108,7 @@ See vignette "spatial" for more operations on geo fields. --> ```{r dl_submissions} data <- ruODK::odata_submission_get( - table = ft$url[1], + table = ft$url[1], local_dir = loc, wkt = TRUE ) @@ -126,9 +127,9 @@ data_sub2 <- ruODK::odata_submission_get( local_dir = loc ) %>% dplyr::left_join( - data, + data, by = c("submissions_id" = "id") -) + ) # repeat for any remaining nested tables ``` @@ -150,15 +151,15 @@ leaflet::leaflet(width = 800, height = 600) %>% leaflet::clearBounds() %>% leaflet::addAwesomeMarkers( data = data, - # + # # # Adjust to your coordinate field names - # - lng = ~location_longitude, - lat = ~location_latitude, + # + lng = ~location_longitude, + lat = ~location_latitude, icon = leaflet::makeAwesomeIcon(text = "Q", markerColor = "red"), - # + # # # With your own field names - # + # # label = ~ glue::glue("{location_area_name} {encounter_start_datetime}"), # popup = ~ glue::glue( # "

{location_area_name}

", @@ -169,7 +170,7 @@ leaflet::leaflet(width = 800, height = 600) %>% # '
My photo
' # ), - # + # # # If there are many submissions, cluster markers for performance: clusterOptions = leaflet::markerClusterOptions() ) %>% @@ -201,7 +202,7 @@ created initially. # Prepare report and products as local files # rep_fn <- "my_report.html" # The file name you save this template under -data_fn <- here::here(loc, "data.csv") %>% as.character() # Main data +data_fn <- here::here(loc, "data.csv") %>% as.character() # Main data data_sub1_fn <- here::here(loc, "data_sub1.csv") %>% as.character() # Sub tbl 1 data_sub2_fn <- here::here(loc, "data_sub2.csv") %>% as.character() # Sub tbl 2 zip_fn <- "products.zip" # Attachments as one zip file (top level) @@ -217,7 +218,7 @@ zip(zipfile = zip_fn, files = fs::dir_ls(loc)) #------------------------------------------------------------------------------# # CKAN # -# Upload to a CKAN data catalogue +# Upload to a CKAN data catalogue # Needs url and API key of a write permitted user # See ROpenSci package ckanr ckanr::ckanr_setup(url = Sys.getenv("CKAN_URL"), key = Sys.getenv("CKAN_KEY")) @@ -226,20 +227,25 @@ ckan_ds_name <- "my-ckan-dataset-slug" # Run once to create resources on an existing dataset, then comment out d <- ckanr::package_show(ckan_ds_name) res_data_main <- ckanr::resource_create( - package_id = d$id, name="Main data", upload = data_fn) + package_id = d$id, name = "Main data", upload = data_fn +) res_data_sub1 <- ckanr::resource_create( - package_id = d$id, name="Nested data table 1", upload = data_sub1_fn) + package_id = d$id, name = "Nested data table 1", upload = data_sub1_fn +) res_data_sub2 <- ckanr::resource_create( - package_id = d$id, name="Nested data table 2", upload = data_sub2_fn) + package_id = d$id, name = "Nested data table 2", upload = data_sub2_fn +) # add remaining tables if (fs::file_exists(rep_fn)) { -res_report <- ckanr::resource_create( - package_id = d$id, name="Data report", upload = rep_fn) + res_report <- ckanr::resource_create( + package_id = d$id, name = "Data report", upload = rep_fn + ) } if (fs::file_exists(zip_fn)) { -res_zip <- ckanr::resource_create( - package_id = d$id, name="All data and attachments", upload = zip_fn) + res_zip <- ckanr::resource_create( + package_id = d$id, name = "All data and attachments", upload = zip_fn + ) } # Paste res_data_main$id over RID and keep here, repeat for each resource r <- ckanr::resource_update(res_data_main$id, path = data_fn) @@ -258,12 +264,12 @@ googledrive::drive_auth(use_oob = TRUE) # Upload to Google Drive gd_fn <- "My Google Drive folder name" -googledrive::drive_ls(gd_fn) %>% googledrive::drive_rm(.) # Wipe older outputs +googledrive::drive_ls(gd_fn) %>% googledrive::drive_rm(.) # Wipe older outputs if (fs::file_exists(rep_fn)) { - googledrive::drive_upload(rep_fn, path=rep_fn) # Report as HTML + googledrive::drive_upload(rep_fn, path = rep_fn) # Report as HTML } -googledrive::drive_upload(data_fn, path=data_fn) # Main data as CSV -googledrive::drive_upload(data_sub1_fn, path=data_sub1_fn) # Sub table 1 as CSV -googledrive::drive_upload(data_sub2_fn, path=data_sub2_fn) # Sub table 2 as CSV -googledrive::drive_upload(zip_fn, path=zip_fn) # All outputs as ZIP +googledrive::drive_upload(data_fn, path = data_fn) # Main data as CSV +googledrive::drive_upload(data_sub1_fn, path = data_sub1_fn) # Sub table 1 as CSV +googledrive::drive_upload(data_sub2_fn, path = data_sub2_fn) # Sub table 2 as CSV +googledrive::drive_upload(zip_fn, path = zip_fn) # All outputs as ZIP ``` diff --git a/man-roxygen/param-did.R b/man-roxygen/param-did.R new file mode 100644 index 0000000..f26e7e4 --- /dev/null +++ b/man-roxygen/param-did.R @@ -0,0 +1 @@ +#' @param did (chr) The name of the Entity List, internally called Dataset. diff --git a/man/entitylist_detail.Rd b/man/entitylist_detail.Rd new file mode 100644 index 0000000..b863bb6 --- /dev/null +++ b/man/entitylist_detail.Rd @@ -0,0 +1,121 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/entitylist_detail.R +\name{entitylist_detail} +\alias{entitylist_detail} +\title{Show Entity List details.} +\usage{ +entitylist_detail( + pid = get_default_pid(), + did = NULL, + url = get_default_url(), + un = get_default_un(), + pw = get_default_pw(), + retries = get_retries(), + odkc_version = get_default_odkc_version(), + orders = c("YmdHMS", "YmdHMSz", "Ymd HMS", "Ymd HMSz", "Ymd", "ymd"), + tz = get_default_tz() +) +} +\arguments{ +\item{pid}{The numeric ID of the project, e.g.: 2. + +Default: \code{\link{get_default_pid}}. + +Set default \code{pid} through \code{ru_setup(pid="...")}. + +See \code{vignette("Setup", package = "ruODK")}.} + +\item{did}{(chr) The name of the Entity List, internally called Dataset.} + +\item{url}{The ODK Central base URL without trailing slash. + +Default: \code{\link{get_default_url}}. + +Set default \code{url} through \code{ru_setup(url="...")}. + +See \code{vignette("Setup", package = "ruODK")}.} + +\item{un}{The ODK Central username (an email address). +Default: \code{\link{get_default_un}}. +Set default \code{un} through \code{ru_setup(un="...")}. +See \code{vignette("Setup", package = "ruODK")}.} + +\item{pw}{The ODK Central password. +Default: \code{\link{get_default_pw}}. +Set default \code{pw} through \code{ru_setup(pw="...")}. +See \code{vignette("Setup", package = "ruODK")}.} + +\item{retries}{The number of attempts to retrieve a web resource. + +This parameter is given to \code{\link[httr]{RETRY}(times = retries)}. + +Default: 3.} + +\item{odkc_version}{The ODK Central version as a semantic version string +(year.minor.patch), e.g. "2023.5.1". The version is shown on ODK Central's +version page \verb{/version.txt}. Discard the "v". +\code{ruODK} uses this parameter to adjust for breaking changes in ODK Central. + +Default: \code{\link{get_default_odkc_version}} or "2023.5.1" if unset. + +Set default \code{get_default_odkc_version} through +\code{ru_setup(odkc_version="2023.5.1")}. + +See \code{vignette("Setup", package = "ruODK")}.} + +\item{orders}{(vector of character) Orders of datetime elements for +lubridate. + +Default: +\code{c("YmdHMS", "YmdHMSz", "Ymd HMS", "Ymd HMSz", "Ymd", "ymd")}.} + +\item{tz}{A timezone to convert dates and times to. + +Read \code{vignette("setup", package = "ruODK")} to learn how \code{ruODK}'s +timezone can be set globally or per function.} +} +\value{ +A list of lists following the exact format and naming of the API +response. Since this nested list is so deeply nested and irregularly shaped +it is not trivial to rectangle the result into a tibble. +} +\description{ +\ifelse{html}{\href{https://lifecycle.r-lib.org/articles/stages.html#maturing}{\figure{lifecycle-maturing.svg}{options: alt='[Maturing]'}}}{\strong{[Maturing]}} +} +\details{ +An Entity List is a named collection of Entities that have the same +properties. +Entity List can be linked to Forms as Attachments. +This will make it available to clients as an automatically-updating CSV. + +This function is supported from ODK Central v2022.3 and will warn if the +given odkc_version is lower. +} +\examples{ +\dontrun{ +# See vignette("setup") for setup and authentication options +# ruODK::ru_setup(svc = "....svc", un = "me@email.com", pw = "...") + +ds <- entitylist_list(pid = get_default_pid()) +ds1 <- entitylist_detail(pid = get_default_pid(), did = ds$name[1]) + +ds1 |> listviewer::jsonedit() +ds1$linkedForms |> + purrr::list_transpose() |> + tibble::as_tibble() +ds1$sourceForms |> + purrr::list_transpose() |> + tibble::as_tibble() +ds1$properties |> + purrr::list_transpose() |> + tibble::as_tibble() +} +} +\seealso{ +\url{ https://docs.getodk.org/central-api-dataset-management/#datasets} + +Other entity-management: +\code{\link{entitylist_download}()}, +\code{\link{entitylist_list}()} +} +\concept{entity-management} diff --git a/man/entitylist_download.Rd b/man/entitylist_download.Rd new file mode 100644 index 0000000..75b43fa --- /dev/null +++ b/man/entitylist_download.Rd @@ -0,0 +1,194 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/entitylist_download.R +\name{entitylist_download} +\alias{entitylist_download} +\title{Download an Entity List as CSV.} +\usage{ +entitylist_download( + pid = get_default_pid(), + did = NULL, + url = get_default_url(), + un = get_default_un(), + pw = get_default_pw(), + local_dir = here::here(), + filter = NULL, + etag = NULL, + overwrite = TRUE, + retries = get_retries(), + odkc_version = get_default_odkc_version(), + orders = c("YmdHMS", "YmdHMSz", "Ymd HMS", "Ymd HMSz", "Ymd", "ymd"), + tz = get_default_tz(), + verbose = get_ru_verbose() +) +} +\arguments{ +\item{pid}{The numeric ID of the project, e.g.: 2. + +Default: \code{\link{get_default_pid}}. + +Set default \code{pid} through \code{ru_setup(pid="...")}. + +See \code{vignette("Setup", package = "ruODK")}.} + +\item{did}{(chr) The name of the Entity List, internally called Dataset.} + +\item{url}{The ODK Central base URL without trailing slash. + +Default: \code{\link{get_default_url}}. + +Set default \code{url} through \code{ru_setup(url="...")}. + +See \code{vignette("Setup", package = "ruODK")}.} + +\item{un}{The ODK Central username (an email address). +Default: \code{\link{get_default_un}}. +Set default \code{un} through \code{ru_setup(un="...")}. +See \code{vignette("Setup", package = "ruODK")}.} + +\item{pw}{The ODK Central password. +Default: \code{\link{get_default_pw}}. +Set default \code{pw} through \code{ru_setup(pw="...")}. +See \code{vignette("Setup", package = "ruODK")}.} + +\item{local_dir}{The local folder to save the downloaded files to, +default: \code{here::here}. +If the folder does not exist it will be created.} + +\item{filter}{(str) A valid filter string. +Default: NULL (no filtering, all entities returned).} + +\item{etag}{(str) The etag value from a previous call to +\code{entitylist_download()}. The value must be stripped of the \verb{W/\\"} and \verb{\\"}, +which is the format of the etag returned by \code{entitylist_download()}. +If provided, only new entities will be returned. +If the same \code{local_dir} is chosen and \code{overwrite} is set to \code{TRUE}, +the downloaded CSV will also be overwritte, losing the Entities downloaded +earlier. +Default: NULL (no filtering, all entities returned).} + +\item{overwrite}{Whether to overwrite previously downloaded file, +default: FALSE} + +\item{retries}{The number of attempts to retrieve a web resource. + +This parameter is given to \code{\link[httr]{RETRY}(times = retries)}. + +Default: 3.} + +\item{odkc_version}{The ODK Central version as a semantic version string +(year.minor.patch), e.g. "2023.5.1". The version is shown on ODK Central's +version page \verb{/version.txt}. Discard the "v". +\code{ruODK} uses this parameter to adjust for breaking changes in ODK Central. + +Default: \code{\link{get_default_odkc_version}} or "2023.5.1" if unset. + +Set default \code{get_default_odkc_version} through +\code{ru_setup(odkc_version="2023.5.1")}. + +See \code{vignette("Setup", package = "ruODK")}.} + +\item{orders}{(vector of character) Orders of datetime elements for +lubridate. + +Default: +\code{c("YmdHMS", "YmdHMSz", "Ymd HMS", "Ymd HMSz", "Ymd", "ymd")}.} + +\item{tz}{A timezone to convert dates and times to. + +Read \code{vignette("setup", package = "ruODK")} to learn how \code{ruODK}'s +timezone can be set globally or per function.} + +\item{verbose}{Whether to display debug messages or not. + +Read \code{vignette("setup", package = "ruODK")} to learn how \code{ruODK}'s +verbosity can be set globally or per function.} +} +\value{ +A list of four items: +\itemize{ +\item entities (tbl_df) The Entity List as tibble +\item http_status (int) The HTTP status code of the response. +200 if OK, 304 if a given etag finds no new entities created. +\item etag (str) The ETag to use in subsequent calls to \code{entitylist_download()} +\item downloaded_to (fs_path) The path to the downloaded CSV file +\item downloaded_on (POSIXct) The time of download in the local timezome +} +} +\description{ +\ifelse{html}{\href{https://lifecycle.r-lib.org/articles/stages.html#maturing}{\figure{lifecycle-maturing.svg}{options: alt='[Maturing]'}}}{\strong{[Maturing]}} +} +\details{ +The downloaded CSV file is named after the entity list name. +The download location defaults to the current workdir, but can be modified +to a different folder path which will be created if it doesn't exist. + +An Entity List is a named collection of Entities that have the same +properties. +Entity List can be linked to Forms as Attachments. +This will make it available to clients as an automatically-updating CSV. + +Entity Lists can be used as Attachments in other Forms, but they can also be +downloaded directly as a CSV file. +The CSV format closely matches the OData Dataset (Entity List) Service +format, with columns for system properties such as \verb{__id} (the Entity UUID), +\verb{__createdAt}, \verb{__creatorName}, etc., the Entity Label label, and the +Dataset (Entity List )/Entity Properties themselves. +If any Property for an given Entity is blank (e.g. it was not captured by +that Form or was left blank), that field of the CSV is blank. + +The ODK Central \verb{$filter} querystring parameter can be used to filter on +system-level properties, similar to how filtering in the OData Dataset +(Entity List) Service works. +Of the \href{https://docs.oasis-open.org/odata/odata/v4.01/odata-v4.01-part1-protocol.html#_Toc31358948}{OData filter specs} +ODK Central implements a \href{https://docs.getodk.org/central-api-odata-endpoints/#data-document}{growing set of features }. +\code{ruODK} provides the parameter \code{filter} (str) which, if set, will be passed +on to the ODK Central endpoint as is. + +The ODK Central endpoint supports the \href{https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/ETag}{\code{ETag} header }, which can +be used to avoid downloading the same content more than once. +When an API consumer calls this endpoint, the endpoint returns a value in +the \code{ETag} header. +If you pass that value in the \href{https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/If-None-Match}{\code{If-None-Match} header } +of a subsequent request, +then if the Entity List has not been changed since the previous request, +you will receive 304 Not Modified response; otherwise you'll get the new +data. +\code{ruODK} provides the parameter \code{etag} which can be set from the output of +a previous call to \code{entitylist_download()}. \code{ruODK} strips the \verb{W/\\"} and +\verb{\\"} from the returned etag and expects the stripped etag as parameter. +} +\examples{ +\dontrun{ +# See vignette("setup") for setup and authentication options +# ruODK::ru_setup(svc = "....svc", un = "me@email.com", pw = "...") + +ds <- entitylist_list(pid = get_default_pid()) +ds1 <- entitylist_download(pid = get_default_pid(), did = ds$name[1]) +# ds1$entities +# ds1$etag +# ds1$downloaded_to +# ds1$downloaded_on + +ds2 <- entitylist_download( + pid = get_default_pid(), + did = ds$name[1], + etag = ds1$etag +) +# ds2$http_status == 304 + +newest_entity_date <- as.Date(max(ds1$entities$`__createdAt`)) +ds3 <- entitylist_download( + pid = get_default_pid(), + did = ds$name[1], + filter = glue::glue("__createdAt le {newest_entity_date}") +) +} +} +\seealso{ +\url{https://docs.getodk.org/central-api-dataset-management/#datasets} + +Other entity-management: +\code{\link{entitylist_detail}()}, +\code{\link{entitylist_list}()} +} +\concept{entity-management} diff --git a/man/entitylist_list.Rd b/man/entitylist_list.Rd new file mode 100644 index 0000000..560d5a1 --- /dev/null +++ b/man/entitylist_list.Rd @@ -0,0 +1,115 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/entitylist_list.R +\name{entitylist_list} +\alias{entitylist_list} +\title{List all Entity Lists of one Project.} +\usage{ +entitylist_list( + pid = get_default_pid(), + url = get_default_url(), + un = get_default_un(), + pw = get_default_pw(), + retries = get_retries(), + odkc_version = get_default_odkc_version(), + orders = c("YmdHMS", "YmdHMSz", "Ymd HMS", "Ymd HMSz", "Ymd", "ymd"), + tz = get_default_tz() +) +} +\arguments{ +\item{pid}{The numeric ID of the project, e.g.: 2. + +Default: \code{\link{get_default_pid}}. + +Set default \code{pid} through \code{ru_setup(pid="...")}. + +See \code{vignette("Setup", package = "ruODK")}.} + +\item{url}{The ODK Central base URL without trailing slash. + +Default: \code{\link{get_default_url}}. + +Set default \code{url} through \code{ru_setup(url="...")}. + +See \code{vignette("Setup", package = "ruODK")}.} + +\item{un}{The ODK Central username (an email address). +Default: \code{\link{get_default_un}}. +Set default \code{un} through \code{ru_setup(un="...")}. +See \code{vignette("Setup", package = "ruODK")}.} + +\item{pw}{The ODK Central password. +Default: \code{\link{get_default_pw}}. +Set default \code{pw} through \code{ru_setup(pw="...")}. +See \code{vignette("Setup", package = "ruODK")}.} + +\item{retries}{The number of attempts to retrieve a web resource. + +This parameter is given to \code{\link[httr]{RETRY}(times = retries)}. + +Default: 3.} + +\item{odkc_version}{The ODK Central version as a semantic version string +(year.minor.patch), e.g. "2023.5.1". The version is shown on ODK Central's +version page \verb{/version.txt}. Discard the "v". +\code{ruODK} uses this parameter to adjust for breaking changes in ODK Central. + +Default: \code{\link{get_default_odkc_version}} or "2023.5.1" if unset. + +Set default \code{get_default_odkc_version} through +\code{ru_setup(odkc_version="2023.5.1")}. + +See \code{vignette("Setup", package = "ruODK")}.} + +\item{orders}{(vector of character) Orders of datetime elements for +lubridate. + +Default: +\code{c("YmdHMS", "YmdHMSz", "Ymd HMS", "Ymd HMSz", "Ymd", "ymd")}.} + +\item{tz}{A timezone to convert dates and times to. + +Read \code{vignette("setup", package = "ruODK")} to learn how \code{ruODK}'s +timezone can be set globally or per function.} +} +\value{ +A tibble with exactly one row for each dataset of the given project +as per ODK Central API docs. +Column names are renamed from ODK's \code{camelCase} to \code{snake_case}. +} +\description{ +\ifelse{html}{\href{https://lifecycle.r-lib.org/articles/stages.html#maturing}{\figure{lifecycle-maturing.svg}{options: alt='[Maturing]'}}}{\strong{[Maturing]}} +} +\details{ +While the API endpoint will return all Entity Lists for one Project, +\code{\link{entitylist_list}} will fail with incorrect or missing +authentication. + +An Entity List is a named collection of Entities that have the same properties. +An Entity List can be linked to Forms as Attachments. +This will make it available to clients as an automatically-updating CSV. + +ODK Central calls Entity Lists internally Datasets. \code{ruODK} chooses the term +Entity Lists as it is used in the ODK Central user interface and conveys +its relation to Entities better than the term Dataset. + +This function is supported from ODK Central v2022.3 and will warn if the +given odkc_version is lower. +} +\examples{ +\dontrun{ +# See vignette("setup") for setup and authentication options +# ruODK::ru_setup(svc = "....svc", un = "me@email.com", pw = "...") + +ds <- entitylist_list(pid = get_default_pid()) + +ds |> knitr::kable() +} +} +\seealso{ +\url{ https://docs.getodk.org/central-api-dataset-management/#datasets} + +Other entity-management: +\code{\link{entitylist_detail}()}, +\code{\link{entitylist_download}()} +} +\concept{entity-management} diff --git a/man/entitylist_update.Rd b/man/entitylist_update.Rd new file mode 100644 index 0000000..9ad5662 --- /dev/null +++ b/man/entitylist_update.Rd @@ -0,0 +1,137 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/entitylist_update.R +\name{entitylist_update} +\alias{entitylist_update} +\title{Update Entity List details.} +\usage{ +entitylist_update( + pid = get_default_pid(), + did = NULL, + approval_required = FALSE, + url = get_default_url(), + un = get_default_un(), + pw = get_default_pw(), + retries = get_retries(), + odkc_version = get_default_odkc_version(), + orders = c("YmdHMS", "YmdHMSz", "Ymd HMS", "Ymd HMSz", "Ymd", "ymd"), + tz = get_default_tz() +) +} +\arguments{ +\item{pid}{The numeric ID of the project, e.g.: 2. + +Default: \code{\link{get_default_pid}}. + +Set default \code{pid} through \code{ru_setup(pid="...")}. + +See \code{vignette("Setup", package = "ruODK")}.} + +\item{did}{(chr) The name of the Entity List, internally called Dataset.} + +\item{approval_required}{(lgl) The value to set approvalRequired to. +If TRUE, a submission must be approved before an entity is created, +if FALSE, an entity is created as soon as the submission is received by +ODK Central. +Default: FALSE.} + +\item{url}{The ODK Central base URL without trailing slash. + +Default: \code{\link{get_default_url}}. + +Set default \code{url} through \code{ru_setup(url="...")}. + +See \code{vignette("Setup", package = "ruODK")}.} + +\item{un}{The ODK Central username (an email address). +Default: \code{\link{get_default_un}}. +Set default \code{un} through \code{ru_setup(un="...")}. +See \code{vignette("Setup", package = "ruODK")}.} + +\item{pw}{The ODK Central password. +Default: \code{\link{get_default_pw}}. +Set default \code{pw} through \code{ru_setup(pw="...")}. +See \code{vignette("Setup", package = "ruODK")}.} + +\item{retries}{The number of attempts to retrieve a web resource. + +This parameter is given to \code{\link[httr]{RETRY}(times = retries)}. + +Default: 3.} + +\item{odkc_version}{The ODK Central version as a semantic version string +(year.minor.patch), e.g. "2023.5.1". The version is shown on ODK Central's +version page \verb{/version.txt}. Discard the "v". +\code{ruODK} uses this parameter to adjust for breaking changes in ODK Central. + +Default: \code{\link{get_default_odkc_version}} or "2023.5.1" if unset. + +Set default \code{get_default_odkc_version} through +\code{ru_setup(odkc_version="2023.5.1")}. + +See \code{vignette("Setup", package = "ruODK")}.} + +\item{orders}{(vector of character) Orders of datetime elements for +lubridate. + +Default: +\code{c("YmdHMS", "YmdHMSz", "Ymd HMS", "Ymd HMSz", "Ymd", "ymd")}.} + +\item{tz}{A timezone to convert dates and times to. + +Read \code{vignette("setup", package = "ruODK")} to learn how \code{ruODK}'s +timezone can be set globally or per function.} +} +\value{ +A list of lists following the exact format and naming of the API +response for \code{entitylist_detail}. +Since this nested list is so deeply nested and irregularly shaped +it is not trivial to rectangle the result into a tibble. +} +\description{ +\ifelse{html}{\href{https://lifecycle.r-lib.org/articles/stages.html#maturing}{\figure{lifecycle-maturing.svg}{options: alt='[Maturing]'}}}{\strong{[Maturing]}} +} +\details{ +You can only update \code{approvalRequired} using this endpoint. +The approvalRequired flag controls the Entity creation flow; +if it is true then the Submission must be approved before an Entity can be +created from it and if it is false then an Entity is created as soon as the +Submission is received by the ODK Central. +By default \code{approvalRequired} is false for the Entity Lists created after +v2023.3. Entity Lists created prior to that will have approvalRequired set to +true. + +An Entity List is a named collection of Entities that have the same +properties. +An Entity List can be linked to Forms as Attachments. +This will make it available to clients as an automatically-updating CSV. + +This function is supported from ODK Central v2022.3 and will warn if the +given odkc_version is lower. + +\ifelse{html}{\href{https://lifecycle.r-lib.org/articles/stages.html#maturing}{\figure{lifecycle-maturing.svg}{options: alt='[Maturing]'}}}{\strong{[Maturing]}} +} +\examples{ +\dontrun{ +# See vignette("setup") for setup and authentication options +# ruODK::ru_setup(svc = "....svc", un = "me@email.com", pw = "...") + +pid <- get_default_pid() + +ds <- entitylist_list(pid = pid) + +did <- ds$name[1] + +ds1 <- entitylist_detail(pid = pid, did = did) +ds1$approvalRequired # FALSE + +ds2 <- entitylist_update(pid = pid, did = did, approval_required = TRUE) +ds2$approvalRequired # TRUE + +ds3 <- entitylist_update(pid = pid, did = did, approval_required = FALSE) +ds3$approvalRequired # FALSE +} +} +\seealso{ +\url{ https://docs.getodk.org/central-api-dataset-management/#datasets} +} +\concept{dataset-management} diff --git a/man/semver_gt.Rd b/man/semver_gt.Rd index c1097a6..5cc244c 100644 --- a/man/semver_gt.Rd +++ b/man/semver_gt.Rd @@ -25,15 +25,9 @@ Show whether a given semver is greater than a baseline version. } \examples{ get_default_odkc_version() |> semver_gt("0.8.0") -"2024.1.1" |> - parse_odkc_version() |> - semver_gt("2024.1.0") -"2024.1.1" |> - parse_odkc_version() |> - semver_gt("2024.1.1") -"2024.1.1" |> - parse_odkc_version() |> - semver_gt("2024.1.2") +"2024.1.1" |> semver_gt("2024.1.0") +"2024.1.1" |> semver_gt("2024.1.1") +"2024.1.1" |> semver_gt("2024.1.2") } \seealso{ Other ru_settings: diff --git a/man/semver_lt.Rd b/man/semver_lt.Rd index 5011ebd..dbc23c9 100644 --- a/man/semver_lt.Rd +++ b/man/semver_lt.Rd @@ -25,15 +25,9 @@ Show whether a given semver is lesser than a baseline version. } \examples{ get_default_odkc_version() |> semver_lt("0.8.0") -"2024.1.1" |> - parse_odkc_version() |> - semver_lt("2024.1.0") -"2024.1.1" |> - parse_odkc_version() |> - semver_lt("2024.1.1") -"2024.1.1" |> - parse_odkc_version() |> - semver_lt("2024.1.2") +"2024.1.1" |> semver_lt("2024.1.0") +"2024.1.1" |> semver_lt("2024.1.1") +"2024.1.1" |> semver_lt("2024.1.2") } \seealso{ Other ru_settings: diff --git a/tests/testthat/test-entitylist_detail.R b/tests/testthat/test-entitylist_detail.R new file mode 100644 index 0000000..b2f502e --- /dev/null +++ b/tests/testthat/test-entitylist_detail.R @@ -0,0 +1,70 @@ +test_that("entitylist_detail works", { + ru_setup( + pid = get_test_pid(), + url = get_test_url(), + un = get_test_un(), + pw = get_test_pw(), + odkc_version = get_test_odkc_version() + ) + + ds <- entitylist_list() + did <- ds$name[1] + + ds1 <- entitylist_detail(did = did) + + # entitylist_detail returns a list + testthat::expect_is(ds1, "list") + + # linkedForms contain form xmlFormId and name + lf <- ds1$linkedForms |> + purrr::list_transpose() |> + tibble::as_tibble() + testthat::expect_equal(names(lf), c("xmlFormId", "name")) + + # sourceForms contain form xmlFormId and name + sf <- ds1$sourceForms |> + purrr::list_transpose() |> + tibble::as_tibble() + testthat::expect_equal(names(sf), c("xmlFormId", "name")) + + # properties lists attributes of entities + pr <- ds1$properties |> + purrr::list_transpose() |> + tibble::as_tibble() + testthat::expect_equal( + names(pr), + c("name", "publishedAt", "odataName", "forms") + ) +}) + +test_that("entitylist_detail errors if did is missing", { + testthat::expect_error( + entitylist_detail() + ) +}) + +test_that("entitylist_detail warns if odkc_version too low", { + skip_if(Sys.getenv("ODKC_TEST_URL") == "", + message = "Test server not configured" + ) + + ru_setup( + pid = get_test_pid(), + url = get_test_url(), + un = get_test_un(), + pw = get_test_pw(), + odkc_version = get_test_odkc_version() + ) + + ds <- entitylist_list() + did <- ds$name[1] + + ds1 <- entitylist_detail(did = did) + + testthat::expect_warning( + ds1 <- entitylist_detail(did = did, odkc_version = "1.5.3") + ) +}) + + +# usethis::use_r("entitylist_detail") # nolint diff --git a/tests/testthat/test-entitylist_download.R b/tests/testthat/test-entitylist_download.R new file mode 100644 index 0000000..f0281f9 --- /dev/null +++ b/tests/testthat/test-entitylist_download.R @@ -0,0 +1,165 @@ +test_that("entitylist_download works", { + skip_if(Sys.getenv("ODKC_TEST_URL") == "", + message = "Test server not configured" + ) + + # skip_on_ci() + + tempd <- fs::path(tempdir(), "new_dir") + + ru_setup( + pid = get_test_pid(), + url = get_test_url(), + un = get_test_un(), + pw = get_test_pw(), + odkc_version = get_test_odkc_version() + ) + + ds <- entitylist_list() + + ds1 <- entitylist_download(did = ds$name[1], local_dir = tempd) + + # Format: + # list( + # entities = httr::content(res, encoding = "utf-8"), + # etag = res$headers$etag, + # downloaded_to = pth, + # downloaded_on = isodt_to_local(res$date, orders = orders, tz = tz) + # ) + + # The Entity List is also returned as a tibble + testthat::expect_s3_class(ds1$entities, "tbl_df") + + # An ETag was returned + testthat::expect_is(ds1$etag, "character") + + # The CSV file was downloaded + testthat::expect_true(fs::file_exists(ds1$downloaded_to)) + + # The timestamp is included + testthat::expect_s3_class(ds1$downloaded_on, "POSIXct") + + # Download to same location, do not overwrite: error + # Error: Path exists and overwrite is FALSE + testthat::expect_error( + entitylist_download( + did = ds$name[1], + local_dir = tempd, + overwrite = FALSE + ) + ) + + ds2 <- entitylist_download( + did = ds$name[1], + local_dir = tempd, + overwrite = TRUE + ) + + # The returned file path is the same as from the first download + testthat::expect_equal(ds1$downloaded_to, ds2$downloaded_to) + + # Clean up + fs::dir_ls(tempd) %>% fs::file_delete() +}) + + +test_that("entitylist_download etag works", { + skip_if(Sys.getenv("ODKC_TEST_URL") == "", + message = "Test server not configured" + ) + + # skip_on_ci() + + tempd <- tempdir() + fs::dir_ls(tempd) %>% fs::file_delete() + + ru_setup( + pid = get_test_pid(), + url = get_test_url(), + un = get_test_un(), + pw = get_test_pw(), + odkc_version = get_test_odkc_version() + ) + + ds <- entitylist_list() + + ds1 <- entitylist_download(did = ds$name[1], local_dir = tempd) + + # Download only entities added since last download (ds1) = None + ds2 <- entitylist_download( + did = ds$name[1], + local_dir = tempd, + overwrite = TRUE, + etag = ds1$etag + ) + + testthat::expect_equal(ds2$http_status, 304) + testthat::expect_equal(ds2$entities, NULL) +}) + +test_that("entitylist_download filter works", { + skip_if(Sys.getenv("ODKC_TEST_URL") == "", + message = "Test server not configured" + ) + + # skip_on_ci() + + tempd <- tempdir() + fs::dir_ls(tempd) %>% fs::file_delete() + + ru_setup( + pid = get_test_pid(), + url = get_test_url(), + un = get_test_un(), + pw = get_test_pw(), + odkc_version = get_test_odkc_version() + ) + + ds <- entitylist_list() + + ds1 <- entitylist_download(did = ds$name[1], local_dir = tempd) + + newest_entity_date <- as.Date(max(ds1$entities$`__createdAt`)) + + # Should return all entities (created before or on date of latest entity) + # Currently returns HTTP 501 not implemented + # ds2 <- entitylist_download( + # did = ds$name[1], + # filter=glue::glue("__createdAt le {newest_entity_date}") + # ) + + # testthat::expect_equal(ds2$http_status, 200) + # testthat::expect_true(nrow(ds2$entities)) +}) + +test_that("entitylist_download errors if did is missing", { + testthat::expect_error( + entitylist_download() + ) +}) + +test_that("entitylist_download warns if odkc_version too low", { + tempd <- tempdir() + fs::dir_ls(tempd) %>% fs::file_delete() + + ru_setup( + pid = get_test_pid(), + url = get_test_url(), + un = get_test_un(), + pw = get_test_pw(), + odkc_version = get_test_odkc_version() + ) + + ds <- entitylist_list() + + testthat::expect_warning( + entitylist_download( + did = ds$name[1], + local_dir = tempd, + odkc_version = "1.5.3" + ) + ) +}) + + +# usethis::use_r("entitylist_download") # nolint diff --git a/tests/testthat/test-entitylist_list.R b/tests/testthat/test-entitylist_list.R new file mode 100644 index 0000000..f12572a --- /dev/null +++ b/tests/testthat/test-entitylist_list.R @@ -0,0 +1,58 @@ +test_that("entitylist_list works", { + skip_if(Sys.getenv("ODKC_TEST_URL") == "", + message = "Test server not configured" + ) + + ru_setup( + pid = get_test_pid(), + url = get_test_url(), + un = get_test_un(), + pw = get_test_pw(), + odkc_version = get_test_odkc_version() + ) + + ds <- entitylist_list() + testthat::expect_true(nrow(ds) > 0) + testthat::expect_true("name" %in% names(ds)) + + # function returns a tibble + testthat::expect_s3_class(ds, "tbl_df") + + # Expected column names + cn <- c( + "name", + "created_at", + "project_id", + "approval_required", + "entities", + "last_entity", + "conflicts" + ) + testthat::expect_equal(names(ds), cn) +}) + + +test_that("entitylist_list warns if odkc_version too low", { + skip_if(Sys.getenv("ODKC_TEST_URL") == "", + message = "Test server not configured" + ) + + ru_setup( + pid = get_test_pid(), + url = get_test_url(), + un = get_test_un(), + pw = get_test_pw(), + odkc_version = get_test_odkc_version() + ) + + ds <- entitylist_list() + did <- ds$name[1] + + ds1 <- entitylist_list() + + testthat::expect_warning( + ds1 <- entitylist_list(odkc_version = "1.5.3") + ) +}) + +# usethis::use_r("entitylist_list") # nolint diff --git a/tests/testthat/test-entitylist_update.R b/tests/testthat/test-entitylist_update.R new file mode 100644 index 0000000..ac59c2d --- /dev/null +++ b/tests/testthat/test-entitylist_update.R @@ -0,0 +1,55 @@ +test_that("entitylist_update works", { + ru_setup( + pid = get_test_pid(), + url = get_test_url(), + un = get_test_un(), + pw = get_test_pw(), + odkc_version = get_test_odkc_version() + ) + + ds <- entitylist_list() + + ds1 <- entitylist_detail(did = ds$name[1]) + + did <- ds$name[1] + + # Update dataset with opposite approvalRequired + ds2 <- entitylist_update(did = did, approval_required = !ds1$approvalRequired) + testthat::expect_false(ds1$approvalRequired == ds2$approvalRequired) + + # Update dataset with opposite approvalRequired again + ds3 <- entitylist_update(did = did, approval_required = !ds2$approvalRequired) + testthat::expect_false(ds2$approvalRequired == ds3$approvalRequired) + testthat::expect_true(ds1$approvalRequired == ds3$approvalRequired) +}) + +test_that("entitylist_update errors if did is missing", { + testthat::expect_error( + entitylist_update() + ) +}) + +test_that("entitylist_update warns if odkc_version too low", { + skip_if(Sys.getenv("ODKC_TEST_URL") == "", + message = "Test server not configured" + ) + ru_setup( + pid = get_test_pid(), + url = get_test_url(), + un = get_test_un(), + pw = get_test_pw(), + odkc_version = get_test_odkc_version() + ) + + ds <- entitylist_list() + did <- ds$name[1] + + ds1 <- entitylist_update(did = did) + + testthat::expect_warning( + ds1 <- entitylist_update(did = did, odkc_version = "1.5.3") + ) +}) + + +# usethis::use_r("entitylist_update") # nolint diff --git a/tests/testthat/test-form_schema.R b/tests/testthat/test-form_schema.R index 4c217a3..4d5454d 100644 --- a/tests/testthat/test-form_schema.R +++ b/tests/testthat/test-form_schema.R @@ -21,6 +21,7 @@ test_that("form_schema works with unpublished draft forms", { form_schema( pid = get_test_pid(), fid = "Locations_draft", + draft = TRUE, url = get_test_url(), un = get_test_un(), pw = get_test_pw(), @@ -34,6 +35,7 @@ test_that("form_schema works with unpublished draft forms", { parse = FALSE, pid = get_test_pid(), fid = "Locations_draft", + draft = TRUE, url = get_test_url(), un = get_test_un(), pw = get_test_pw(), diff --git a/tests/testthat/test-submission_export.R b/tests/testthat/test-submission_export.R index 3ef5a2b..a498657 100644 --- a/tests/testthat/test-submission_export.R +++ b/tests/testthat/test-submission_export.R @@ -5,8 +5,7 @@ test_that("submission_export works", { ) # A fresh litterbox - t <- tempdir() - fs::dir_ls(t) %>% fs::file_delete() + t <- fs::path(tempdir(), "new_dir") # High expectations pth <- fs::path( @@ -101,6 +100,9 @@ test_that("submission_export works", { # Find the payload testthat::expect_true(fid_csv %in% fs::dir_ls(t)) + + # Clean up + fs::dir_ls(t) %>% fs::file_delete() }) test_that("submission_export works with encryption", { diff --git a/tic.R b/tic.R deleted file mode 100644 index f48abca..0000000 --- a/tic.R +++ /dev/null @@ -1,47 +0,0 @@ -# installs dependencies, runs R CMD check, runs covr::codecov() -do_package_checks( - error_on="error", - args = c( - "--no-manual", - "--as-cran", - "--no-vignettes", - "--no-build-vignettes", - "--no-multiarch" - ) -) - -# failed: ‘terra’, ‘sf’, ‘raster’, ‘leafpop’, ‘leaflet’, ‘satellite’, ‘leafem’ -if(ci_get_env("matrix.config.os") == "macOS-latest"){ - get_stage("install") %>% - add_step(step_install_cran("proj4")) %>% - add_step(step_install_github("r-spatial/sf", dependencies = TRUE, force = TRUE)) %>% - add_step(step_install_cran("raster")) %>% - add_step(step_install_cran("leaflet")) %>% - add_step(step_install_cran("leafpop")) %>% - add_step(step_install_github("r-spatial/leafem", dependencies = TRUE)) %>% - add_step(step_install_cran("terra")) -} - -if(ci_get_env("matrix.config.os") == "ubuntu-20.04"){ - get_stage("install") %>% - # add_step(step_install_github(c("tidyverse/readr"))) %>% - # https://stackoverflow.com/q/61875754/2813717 - install proj4 - add_step(step_install_cran("proj4")) %>% - # sf install fixed by cpp11 - add_step(step_install_github("r-lib/cpp11", dependencies = TRUE)) %>% - add_step(step_install_github("r-spatial/sf", dependencies = TRUE, force = TRUE)) %>% - add_step(step_install_github("r-spatial/mapview", dependencies = TRUE)) %>% - add_step(step_install_github("r-spatial/leafem", dependencies = TRUE)) %>% - # add_step(step_install_cran("listviewer")) %>% - # libicui8n not found: fixed by stringi forced install - add_step(step_install_github("gagolews/stringi", dependencies = TRUE, force = TRUE)) -} - -# # rOpenSci build their own docs, see build at -# # https://dev.ropensci.org/job/ruODK/lastBuild/console -# -# if (ci_on_ghactions() && ci_has_env("BUILD_PKGDOWN")) { -# # creates pkgdown site and pushes to gh-pages branch -# # only for the runner with the "BUILD_PKGDOWN" env var set -# do_pkgdown() -# } diff --git a/vignettes/restful-api.Rmd b/vignettes/restful-api.Rmd index 4cf8b45..20a20e5 100644 --- a/vignettes/restful-api.Rmd +++ b/vignettes/restful-api.Rmd @@ -62,13 +62,13 @@ are used by `ruODK`'s other functions (unless specified otherwise). ```{r ru_setup} library(ruODK) -ruODK::ru_setup( - svc = "https://sandbox.central.getodk.org/v1/projects/14/forms/build_Flora-Quadrat-0-4_1564384341.svc", - un = Sys.getenv("ODKC_TEST_UN"), - pw = Sys.getenv("ODKC_TEST_PW"), - tz = "Australia/Perth", - verbose = TRUE -) +# ruODK::ru_setup( +# svc = "Form OData Service URL", +# un = Sys.getenv("ODKC_TEST_UN"), +# pw = Sys.getenv("ODKC_TEST_PW"), +# tz = "Australia/Perth", +# verbose = TRUE +# ) t <- fs::dir_create("media") ``` @@ -311,14 +311,17 @@ In order to import each submission, we need to retrieve the data by ```{r submission_data, eval=F} # One submission -fq_one_submission <- ruODK::get_one_submission(fq_submission_list$instance_id[[1]]) +fq_one_submission <- ruODK::get_one_submission( + fq_submission_list$instance_id[[1]] +) # Multiple submissions fq_submissions <- ruODK::submission_get(fq_submission_list$instance_id) ``` ## Parse submissions -The data in `sub` is one row of the bulk downloaded submissions in `data_quadrat`. +The data in `sub` is one row of the bulk downloaded submissions in +`data_quadrat`. The data in `submissions` represents all (or let's pretend, the selected) submissions in `data_quadrat`. The field `xml` contains the actual submission data including repeating groups. @@ -327,8 +330,8 @@ The structure is different to the output of `ruODK::odata_submission_get`, therefore `ruODK::odata_submission_rectangle` does not work for those, as here we might have repeating groups included in a submission. -This structure could be used for upload into data warehouses accepting nested data -as e.g. JSON. +This structure could be used for upload into data warehouses accepting nested +data as e.g. JSON. ```{r view_submission_data, fig.width=7} if (requireNamespace("listviewer")) { diff --git a/vignettes/setup.Rmd b/vignettes/setup.Rmd index 994fb02..c3cbba0 100644 --- a/vignettes/setup.Rmd +++ b/vignettes/setup.Rmd @@ -107,36 +107,36 @@ calling `ru_setup()` or `Sys.setenv()` with respective arguments. usethis::edit_r_environ(scope = "user") ``` -```{r renviron, eval=FALSE} -ODKC_PID <- 1 -ODKC_FID <- "form_id" -ODKC_URL <- "https://my-instance.getodk.cloud" -ODKC_UN <- "me@email.com" -ODKC_PW <- "..." -ODKC_PP <- "..." -ODKC_VERSION <- "2023.5.1" +```{eval=FALSE} +ODKC_PID=1 +ODKC_FID="form_id" +ODKC_URL="https://my-instance.getodk.cloud" +ODKC_UN="me@email.com" +ODKC_PW="..." +ODKC_PP="..." +ODKC_VERSION="2023.5.1" # ODK Test server -ODKC_TEST_SVC <- "https://ruodk.getodk.cloud/v1/projects/1/forms/Flora-Quadrat-04.svc" -ODKC_TEST_URL <- "https://ruodk.getodk.cloud" -ODKC_TEST_PID <- 1 -ODKC_TEST_PID_ENC <- 2 -ODKC_TEST_PP <- "ThePassphrase" -ODKC_TEST_FID <- "Flora-Quadrat-04" -ODKC_TEST_FID_ZIP <- "Spotlighting-06" -ODKC_TEST_FID_ATT <- "Flora-Quadrat-04-att" -ODKC_TEST_FID_GAP <- "Flora-Quadrat-04-gap" -ODKC_TEST_FID_WKT <- "Locations" -ODKC_TEST_FID_I8N0 <- "I8n_no_lang" -ODKC_TEST_FID_I8N1 <- "I8n_label_lng" -ODKC_TEST_FID_I8N2 <- "I8n_label_choices" -ODKC_TEST_FID_ENC <- "Locations" -ODKC_TEST_VERSION <- "2023.5.1" -RU_VERBOSE <- TRUE -RU_TIMEZONE <- "Australia/Perth" -RU_RETRIES <- 3 -ODKC_TEST_UN <- "..." -ODKC_TEST_PW <- "..." +ODKC_TEST_SVC="https://ruodk.getodk.cloud/v1/projects/1/forms/Flora-Quadrat-04.svc" +ODKC_TEST_URL="https://ruodk.getodk.cloud" +ODKC_TEST_PID=1 +ODKC_TEST_PID_ENC=2 +ODKC_TEST_PP="ThePassphrase" +ODKC_TEST_FID="Flora-Quadrat-04" +ODKC_TEST_FID_ZIP="Spotlighting-06" +ODKC_TEST_FID_ATT="Flora-Quadrat-04-att" +ODKC_TEST_FID_GAP="Flora-Quadrat-04-gap" +ODKC_TEST_FID_WKT="Locations" +ODKC_TEST_FID_I8N0="I8n_no_lang" +ODKC_TEST_FID_I8N1="I8n_label_lng" +ODKC_TEST_FID_I8N2="I8n_label_choices" +ODKC_TEST_FID_ENC="Locations" +ODKC_TEST_VERSION="2023.5.1" +RU_VERBOSE=TRUE +RU_TIMEZONE="Australia/Perth" +RU_RETRIES=3 +ODKC_TEST_UN="..." +ODKC_TEST_PW="..." ``` As an alternative to setting environment variables through `~/.Renviron`, you