// 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 { 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 { 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 { 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::()?, 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 { let catalog = load(cli)?; let history = History::load(&catalog.layout.assessments())?; match sub { UsageCommand::History { item } => { let uids: Vec = 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 = 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) } } }