Exams can be easily developed and debugged locally, but to make them available to your learners, you must deploy the exam to a Shiny server. Deploying the exam requires you to think, at least a little bit, about some of the more technical details of the server infrastructure.
As explained also in the companion vignette on coding exercises, coding exercise allow learners to run arbitrary R code on the server. This is a big security issue, as learners may
You need to take precautions to limit the access learners get to the
server when running exercise code. Most importantly, the user running
the shiny process (not to be confused with the user taking the
exam) should have as restrictive permissions on the server as possible.
See the documentation for your shiny server to find out how to set the
user running the shiny app process. The default
future_evaluator()
sanitizes the environment variables
available to the R process, but the R process still has the same
privileges as the shiny process. You are strongly encouraged to use RAppArmor to
restrict the permissions of the R process executing the learners’
code.
If you don’t have much experience with server administration, setting up the database to store the exam data may be the most intriguing challenge for deploying exams. If you have only a small number of learners (< 30), you can choose to use a local SQLite database via the RSQLite package.
An SQLite database, which is basically a single file, can be easily set up from within R and you don’t need any other software on the server. You must create a separate SQLite database for each exam.
Important: if you choose to use an SQlite database and if you use RStudio Connect, Shiny Server Pro, or any other shiny server where the exam would be distributed across several processes, you must limit it to only 1 process. SQLite databases don’t support concurrent access. You can, however, use multiple processes to evaluate coding exercises, if you use them in your exam.
For larger numbers of learners, there is unfortunately no way around configuring a more potent storage provider.
Setting up the required SQLite database can be done from within R, executed on the server. Note that the path to the database file (in the code chunk below /var/run/examinr-my_first_exam.sqlite must be writable by the user creating the database and the user running the shiny process:
library(RSQLite)
# Create an SQLite database for the exam "my_first_exam"
db_con <- dbConnect(RSQLite::SQLite(), "/var/run/examinr-my_first_exam.sqlite")
# Create the attempts table (SQLite doesn't have a UUID type)
dbExecute(db_con, 'CREATE TABLE attempts (
attempt_id varchar(36) PRIMARY KEY,
user_id varchar(64) NOT NULL,
exam_id varchar(64) NOT NULL,
exam_version varchar(64) NOT NULL,
user_obj text NOT NULL,
seed integer NOT NULL,
started_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
finished_at timestamp,
points text
)')
# Create an index on the attempts table
dbExecute(db_con, 'CREATE INDEX attempts_index ON attempts (user_id, exam_id, exam_version)')
# Create the section data table
dbExecute(db_con, 'CREATE TABLE section_data (
id integer PRIMARY KEY,
attempt_id varchar(36) NOT NULL,
section varchar(64) NOT NULL,
saved_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
section_data text
)')
# Create an index on the section data table
dbExecute(db_con, 'CREATE INDEX section_data_index ON section_data (attempt_id, section)')
This must be done for every exam, such that you have one database file per exam.
In your exam document, you would configure the
dbi_storage_provider()
as follows:
#! context="server-start"
library(pool)
# Create a pool of connections to your SQLite database.
# Important: minSize and maxSize must be 1, as SQLite does not support more than one connection!
db_pool <- dbPool(drv = RSQLite::SQLite(),
dbname = "/var/run/examinr-my_first_exam.sqlite",
minSize = 1,
maxSize = 1)
# Close all connections in the pool shiny stops
shiny::onStop(function () { poolClose(db_pool) })
# Use this database as storage for the exam
exam_config(storage_provider = dbi_storage_provider(db_pool, 'attempts', 'section_data'))