264 lines
9.2 KiB
Rust
264 lines
9.2 KiB
Rust
// SPDX-License-Identifier: Prosperity-3.0.0
|
|
// Copyright Scientific Computing Studio
|
|
// Source: https://git.scient.ing/education/coursebank
|
|
|
|
//! Managing items and building assessments from them.
|
|
//!
|
|
//! [`bank`] and [`assessment`] create and list the two record types. [`assemble`]
|
|
//! draws a new assessment from the pool against a blueprint, and [`usage`] reports
|
|
//! where items have already been used so a draw can avoid repeats.
|
|
|
|
use std::collections::BTreeMap;
|
|
|
|
use coursebank::assessment::{AssessmentFile, Blueprint};
|
|
use coursebank::bank::BankFile;
|
|
use coursebank::date::Date;
|
|
use coursebank::error::{Error, Result};
|
|
use coursebank::history::History;
|
|
use coursebank::layout::Layout;
|
|
use coursebank::select;
|
|
use coursebank::yaml;
|
|
|
|
use crate::cli::{AssembleArgs, AssessmentCommand, BankCommand, Cli, UsageCommand};
|
|
use crate::commands::Outcome;
|
|
use crate::helpers::{
|
|
load, load_record, parse_level_map, parse_string_map, print_record, truncate,
|
|
};
|
|
|
|
/// `bank`: create an empty bank, list banks with counts, or import legacy JSON.
|
|
pub(crate) fn bank(cli: &Cli, sub: &BankCommand) -> Result<Outcome> {
|
|
let layout = Layout::new(&cli.course);
|
|
match sub {
|
|
BankCommand::New { id, title } => {
|
|
let path = layout.banks().join(format!("{id}.yaml"));
|
|
if path.exists() {
|
|
return Err(Error::usage(format!("{} already exists", path.display())));
|
|
}
|
|
let bank = BankFile::skeleton(id, title.as_deref().unwrap_or(id));
|
|
yaml::write(&path, &bank)?;
|
|
println!("wrote {}", path.display());
|
|
Ok(Outcome::Ok)
|
|
}
|
|
BankCommand::List => {
|
|
let catalog = load(cli)?;
|
|
let mut counts: BTreeMap<&str, usize> = BTreeMap::new();
|
|
for entry in &catalog.entries {
|
|
*counts.entry(entry.bank.as_str()).or_insert(0) += 1;
|
|
}
|
|
for (id, meta) in &catalog.banks {
|
|
println!(
|
|
"{:<20} {:>4} item(s) {}",
|
|
id,
|
|
counts.get(id.as_str()).copied().unwrap_or(0),
|
|
meta.title
|
|
);
|
|
}
|
|
Ok(Outcome::Ok)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// `assessment`: create an empty record, list records, or show one in detail.
|
|
///
|
|
/// `show` returns [`Outcome::Findings`] when the record has validation problems.
|
|
pub(crate) fn assessment(cli: &Cli, sub: &AssessmentCommand) -> Result<Outcome> {
|
|
let layout = Layout::new(&cli.course);
|
|
match sub {
|
|
AssessmentCommand::New { id, title, kind } => {
|
|
let path = layout.assessments().join(format!("{id}.yaml"));
|
|
if path.exists() {
|
|
return Err(Error::usage(format!("{} already exists", path.display())));
|
|
}
|
|
let record =
|
|
AssessmentFile::skeleton(id, title.as_deref().unwrap_or(id), kind.as_kind());
|
|
record.save(&path)?;
|
|
println!("wrote {}", path.display());
|
|
Ok(Outcome::Ok)
|
|
}
|
|
AssessmentCommand::List => {
|
|
let records = AssessmentFile::load_all(&layout.assessments())?;
|
|
let history = History::load(&layout.assessments())?;
|
|
let _ = &history;
|
|
if records.is_empty() {
|
|
println!("no assessment records yet");
|
|
return Ok(Outcome::Ok);
|
|
}
|
|
println!(
|
|
"{:<16} {:<10} {:<12} {:>6} {:>8}",
|
|
"ID", "KIND", "DATE", "ITEMS", "POINTS"
|
|
);
|
|
for record in &records {
|
|
println!(
|
|
"{:<16} {:<10} {:<12} {:>6} {:>8.1}",
|
|
record.assessment.id,
|
|
record.assessment.kind.as_str(),
|
|
record
|
|
.assessment
|
|
.date
|
|
.map(|d| d.to_string())
|
|
.unwrap_or_else(|| "-".into()),
|
|
record.items.len(),
|
|
record.total_points(1.0)
|
|
);
|
|
}
|
|
Ok(Outcome::Ok)
|
|
}
|
|
AssessmentCommand::Show { id } => {
|
|
let catalog = load(cli)?;
|
|
let record = load_record(&catalog, id)?;
|
|
print_record(&catalog, &record);
|
|
let issues = catalog.validate_record(&record);
|
|
if !issues.is_empty() {
|
|
println!("\n{} problem(s):", issues.len());
|
|
for issue in &issues {
|
|
println!(" - {issue}");
|
|
}
|
|
return Ok(Outcome::Findings);
|
|
}
|
|
Ok(Outcome::Ok)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// `assemble`: draw a new assessment from the pool against a blueprint.
|
|
///
|
|
/// The blueprint is built from the `--levels`/`--bonus`/`--require` flags and the
|
|
/// various restrictions. Without `--dry-run` the resulting record is written; the
|
|
/// draw is reproducible from `--seed`.
|
|
pub(crate) fn assemble(cli: &Cli, args: &AssembleArgs) -> Result<Outcome> {
|
|
let catalog = load(cli)?;
|
|
let layout = &catalog.layout;
|
|
let path = layout.assessments().join(format!("{}.yaml", args.id));
|
|
if path.exists() && !args.force {
|
|
return Err(Error::usage(format!(
|
|
"{} already exists; pass --force to replace it. Replacing an administered \
|
|
assessment invalidates the responses already stored against it",
|
|
path.display()
|
|
)));
|
|
}
|
|
|
|
let date = match &args.date {
|
|
Some(s) => s.parse::<Date>()?,
|
|
None => Date::today(),
|
|
};
|
|
|
|
let blueprint = Blueprint {
|
|
level_counts: parse_level_map(&args.levels)?,
|
|
bonus_counts: parse_level_map(&args.bonus)?,
|
|
objective_minimums: parse_string_map(&args.require)?,
|
|
lectures: args.lectures.clone(),
|
|
topics: args.topics.clone(),
|
|
banks: args.banks.clone(),
|
|
max_per_bank: args.max_per_bank,
|
|
cooldown_days: Some(args.cooldown),
|
|
seed: Some(args.seed),
|
|
};
|
|
|
|
if blueprint.scored_total() == 0 {
|
|
return Err(Error::usage(
|
|
"no items requested; pass --levels, e.g. --levels 1=6,2=8,3=10,4=6".to_string(),
|
|
));
|
|
}
|
|
|
|
let history = History::load(&layout.assessments())?;
|
|
let selection = select::select(&catalog, &blueprint, &history, date)?;
|
|
|
|
let record = select::to_record(
|
|
&catalog,
|
|
&selection,
|
|
&args.id,
|
|
args.title.as_deref().unwrap_or(&args.id),
|
|
args.kind.as_kind(),
|
|
date,
|
|
args.platform.as_platform(),
|
|
&blueprint,
|
|
args.forms.max(1),
|
|
)?;
|
|
|
|
for note in &selection.notes {
|
|
println!("! {note}");
|
|
}
|
|
if !selection.notes.is_empty() {
|
|
println!();
|
|
}
|
|
|
|
print_record(&catalog, &record);
|
|
|
|
let drift = select::check_blueprint(&record, &catalog.course);
|
|
if !drift.is_empty() {
|
|
println!("\nBlueprint not fully satisfied:");
|
|
for d in &drift {
|
|
println!(" - {d}");
|
|
}
|
|
}
|
|
|
|
if args.dry_run {
|
|
println!("\n(dry run, nothing written)");
|
|
return Ok(Outcome::Ok);
|
|
}
|
|
|
|
record.save(&path)?;
|
|
println!("\nwrote {}", path.display());
|
|
println!(
|
|
"Next: review the draw, then\n coursebank export typst {} --form A\n \
|
|
coursebank export qti {} --form A",
|
|
args.id, args.id
|
|
);
|
|
Ok(Outcome::Ok)
|
|
}
|
|
|
|
/// `usage`: report item use history, or the approved items never used.
|
|
pub(crate) fn usage(cli: &Cli, sub: &UsageCommand) -> Result<Outcome> {
|
|
let catalog = load(cli)?;
|
|
let history = History::load(&catalog.layout.assessments())?;
|
|
|
|
match sub {
|
|
UsageCommand::History { item } => {
|
|
let uids: Vec<String> = match item {
|
|
Some(id) => vec![catalog.resolve(id)?],
|
|
None => catalog.entries.iter().map(|e| e.uid.clone()).collect(),
|
|
};
|
|
println!("{:<34} {:>5} {:<12} WHERE", "ITEM", "USES", "LAST");
|
|
for uid in uids {
|
|
let uses = history.for_item(&uid);
|
|
if uses.is_empty() && item.is_none() {
|
|
continue;
|
|
}
|
|
let where_used: Vec<String> = uses
|
|
.iter()
|
|
.map(|u| format!("{}#{}", u.assessment, u.number))
|
|
.collect();
|
|
println!(
|
|
"{:<34} {:>5} {:<12} {}",
|
|
truncate(&uid, 34),
|
|
uses.len(),
|
|
history
|
|
.last_used(&uid)
|
|
.map(|d| d.to_string())
|
|
.unwrap_or_else(|| "-".into()),
|
|
where_used.join(", ")
|
|
);
|
|
}
|
|
Ok(Outcome::Ok)
|
|
}
|
|
UsageCommand::Unused => {
|
|
let mut unused: Vec<&str> = catalog
|
|
.assemblable()
|
|
.into_iter()
|
|
.filter(|e| history.use_count(&e.uid) == 0)
|
|
.map(|e| e.uid.as_str())
|
|
.collect();
|
|
unused.sort_unstable();
|
|
if unused.is_empty() {
|
|
println!("every approved item has been used at least once");
|
|
return Ok(Outcome::Ok);
|
|
}
|
|
println!("{} approved item(s) never used:", unused.len());
|
|
for uid in unused {
|
|
println!(" {uid}");
|
|
}
|
|
Ok(Outcome::Ok)
|
|
}
|
|
}
|
|
}
|