@@ -0,0 +1,263 @@
|
||||
// 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);
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user