193 lines
7.6 KiB
Markdown
193 lines
7.6 KiB
Markdown
# An assignment on the web
|
|
|
|
This follows a single homework from an assembled record to a published Quarto page whose solutions stay locked until a student enters a password.
|
|
It assumes a course directory with approved items and an assembled assessment; if you do not have one, [`setup`](crate::guide::setup) and [`first_exam`](crate::guide::first_exam) build both.
|
|
|
|
The site export is the third off-Canvas path, alongside the printed exam in [`typst_export`](crate::guide::typst_export) and the plain-text worksheet from `export practice`.
|
|
The difference is where the solutions go: printed on a key you keep, or encrypted into a bundle that ships with the page and unlocks in the browser.
|
|
|
|
## Assemble the homework
|
|
|
|
Assemble it the same way you assemble an exam, with the platform set to the website rather than paper or Canvas:
|
|
|
|
```console
|
|
$ coursebank assemble a1.1 \
|
|
--title "Homework 1" \
|
|
--kind homework --platform other \
|
|
--levels 2=1,3=1 --lectures L1.1 --seed 20260210
|
|
```
|
|
|
|
What lands in `assessments/a1.1.yaml` is a record of the draw.
|
|
The id `a1.1` is the one thing to choose deliberately here: it becomes the bundle's file name and the name the page's gate points at, so keep it URL-safe and stable.
|
|
|
|
A single unshuffled form is fine for homework.
|
|
To hand different students different option orders, add `--forms 2` and export each form separately; the printed choices and the letters the solutions refer to move together, because both come from the form's recorded seed.
|
|
|
|
## Export for the web
|
|
|
|
```console
|
|
$ coursebank export site a1.1 --out build/a1.1 --assets build/static
|
|
password for a1.1: k7m4-9p2q-r8tx-3wn6
|
|
wrote build/a1.1/_questions.qmd
|
|
wrote build/a1.1/a1.1-solutions.json
|
|
wrote build/static/questions.css
|
|
wrote build/static/solutions.js
|
|
|
|
Include in the page with: {{< include _questions.qmd >}}
|
|
```
|
|
|
|
That is four files and a password.
|
|
|
|
`_questions.qmd` is the partial you include from the page.
|
|
`a1.1-solutions.json` is the encrypted bundle, named for the assessment so several assignments can share one site.
|
|
The two files under `build/static` style the questions and perform the unlock; they install once for the whole site, not once per page, so `--assets` is something you run the first time and drop afterward.
|
|
|
|
The password is printed once and written nowhere.
|
|
Record it now.
|
|
It cannot be recovered from the files, which is the point: the bundle is useless without it, so losing it means re-exporting rather than reading it back.
|
|
|
|
## What the partial holds
|
|
|
|
The questions are Quarto fenced divs, with an empty, hidden slot where each solution will land:
|
|
|
|
```text
|
|
::: {.solutions-gate data-bundle="a1.1-solutions.json"}
|
|
:::
|
|
|
|
::: {.q #q-enthalpy-qp-001}
|
|
:::: {.q-head}
|
|
[Question 1]{.q-num} [Single best answer]{.q-kind} [1 point]{.q-points}
|
|
::::
|
|
|
|
:::: {.q-stem}
|
|
A reaction is run in an open flask, so the system stays at the constant
|
|
pressure of the room. The heat the reaction exchanges with its surroundings
|
|
equals the change in which quantity?
|
|
::::
|
|
|
|
:::: {.q-choices}
|
|
1. Enthalpy, $\Delta H$
|
|
2. Internal energy, $\Delta U$
|
|
3. Gibbs free energy, $\Delta G$
|
|
4. Entropy, $\Delta S$
|
|
::::
|
|
|
|
:::: {.qsol data-solution-for="q-enthalpy-qp-001" hidden="true"}
|
|
::::
|
|
:::
|
|
```
|
|
|
|
The choices are printed without letters, and the stylesheet draws the A, B, C, D from their position.
|
|
There is nothing in this file to leak: no `correct` flag, no solution text, no rationale.
|
|
The `.qsol` slot is empty until the browser fills it, and the id in `data-solution-for` is the key the bundle looks up.
|
|
|
|
Math is written in `$ … $` and passed through untouched, so MathJax typesets it in the page.
|
|
|
|
## What the bundle holds
|
|
|
|
Every solution is rendered to HTML, then encrypted:
|
|
|
|
```json
|
|
{
|
|
"v": 1,
|
|
"page": "a1.1",
|
|
"kdf": { "name": "PBKDF2", "hash": "SHA-256", "iterations": 250000, "salt": "jqbE6xIB..." },
|
|
"cipher": "AES-GCM",
|
|
"items": {
|
|
"q-enthalpy-qp-001": { "iv": "8jzuxY...", "ct": "gmzNSlhioU...(+tag)" },
|
|
"q-enthalpy-derive-001": { "iv": "1BddCd...", "ct": "sC8czL547/...(+tag)" }
|
|
}
|
|
}
|
|
```
|
|
|
|
The password derives an AES-256 key through PBKDF2-HMAC-SHA256 at 250,000 iterations over a random salt.
|
|
Each solution is encrypted under its own random IV, with the authentication tag appended to the ciphertext.
|
|
The plaintext HTML never leaves your machine.
|
|
Items are listed in the order the questions appear, not sorted, so the reader's browser can decrypt the first one to check the password before touching the rest.
|
|
|
|
## Wire it into the site
|
|
|
|
Install the two assets once in `_quarto.yml`:
|
|
|
|
```yaml
|
|
format:
|
|
html:
|
|
css:
|
|
- static/questions.css
|
|
include-after-body:
|
|
- static/solutions.js
|
|
```
|
|
|
|
Put `_questions.qmd` and `a1.1-solutions.json` in the page's own directory, and include the partial from the page:
|
|
|
|
```markdown
|
|
---
|
|
title: "Homework 1"
|
|
---
|
|
|
|
{{< include _questions.qmd >}}
|
|
```
|
|
|
|
The gate resolves `data-bundle` relative to the page URL, so keeping the bundle beside the page is enough.
|
|
If your build prunes files it does not see linked, add `resources: ["*-solutions.json"]` to the page front matter so the bundle ships with the render.
|
|
|
|
Render with `quarto render`.
|
|
A reader who opens the page sees the questions and a locked panel; typing the password decrypts the solutions in place, with the math typeset.
|
|
|
|
## Hand out the password, and rotate it
|
|
|
|
Give the password through a channel students already have, such as the course LMS, rather than the site itself.
|
|
|
|
Be clear-eyed about what the lock does.
|
|
It keeps solutions off a public page until someone has the password.
|
|
It does not make them secret in a strong sense: the whole bundle is downloaded, so anyone with the password, or anyone they share it with, can decrypt every item, and the ciphertext is open to an offline guessing attack.
|
|
Eighty bits of password entropy and a quarter-million PBKDF2 iterations make guessing slow, but the right mental model is a lock on a take-home worksheet, not a grading system of record.
|
|
|
|
Rotate the password after the due date by re-running the export, which mints a fresh one and a freshly encrypted bundle:
|
|
|
|
```console
|
|
$ coursebank export site a1.1 --out build/a1.1
|
|
password for a1.1: 2h9k-w4rq-8mnp-x6tv
|
|
...
|
|
```
|
|
|
|
To set a password yourself instead of generating one, pass `--password`.
|
|
Use that only when you have a reason to, such as re-encrypting an unchanged page with a password you already circulated.
|
|
|
|
## Doing this from Rust
|
|
|
|
The CLI is a thin wrapper over `coursebank::site`, which is compiled only with the `site` feature.
|
|
The example below needs `--features site` to build, so it is not run as a doctest:
|
|
|
|
```rust,ignore
|
|
use std::path::Path;
|
|
|
|
use coursebank::assessment::{AssessmentFile, Form};
|
|
use coursebank::site::{self, Options};
|
|
use coursebank::Catalog;
|
|
|
|
fn main() -> coursebank::Result<()> {
|
|
let catalog = Catalog::load(Path::new("."))?;
|
|
let path = catalog.layout.assessments().join("a1.1.yaml");
|
|
let record = AssessmentFile::load(&path)?;
|
|
|
|
// An unshuffled form A; declare a form with a seed to shuffle.
|
|
let form = Form { id: "A".into(), seed: 0, shuffle_items: false, shuffle_options: false };
|
|
|
|
// `password: None` generates a fresh one; pass `Some(_)` to set your own.
|
|
let rendered = site::render(&catalog, &record, Options { form, password: None })?;
|
|
|
|
std::fs::write("build/a1.1/_questions.qmd", &rendered.questions_qmd)?;
|
|
std::fs::write(
|
|
format!("build/a1.1/{}-solutions.json", rendered.page),
|
|
&rendered.solutions_json,
|
|
)?;
|
|
// The password is the one thing not on disk; print it now.
|
|
println!("password for {}: {}", rendered.page, rendered.password);
|
|
|
|
Ok(())
|
|
}
|
|
```
|
|
|
|
`site::assets()` returns the two browser files as name and contents, if you would rather write them from your own code than pass `--assets`.
|