PYQ clone: builder, CBT exam screen, grading, analytics dashboard
Axum + SQLite full-stack copy of the ZenithPrep PYQ practice app. Paper builder with unseen-question exclusion, timed CBT screen with palette and autosave, +4/-1 grading with numerical tolerance, per-question analysis, history, dashboard with weak-subtopic tracking and coach notes.
This commit is contained in:
commit
d3001612b2
17 changed files with 4277 additions and 0 deletions
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
/target
|
||||||
2067
Cargo.lock
generated
Normal file
2067
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
21
Cargo.toml
Normal file
21
Cargo.toml
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
[package]
|
||||||
|
name = "zenith-pyq-clone"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
anyhow = "1.0.104"
|
||||||
|
axum = { version = "0.8.9", features = ["form", "query", "json", "http1", "http2"] }
|
||||||
|
chrono = { version = "0.4.45", features = ["serde"] }
|
||||||
|
serde = { version = "1.0.229", features = ["derive"] }
|
||||||
|
serde_json = "1.0.152"
|
||||||
|
sqlx = { version = "0.9.0", features = ["sqlite", "runtime-tokio", "macros", "chrono", "uuid"] }
|
||||||
|
tokio = { version = "1.53.2", features = ["full"] }
|
||||||
|
tower-cookies = "0.11.0"
|
||||||
|
tower-http = { version = "0.7.1", features = ["fs"] }
|
||||||
|
tracing = "0.1.44"
|
||||||
|
tracing-subscriber = "0.3.23"
|
||||||
|
uuid = { version = "1.28.0", features = ["v4", "serde"] }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
serde_urlencoded = "0.7.1"
|
||||||
58
README.md
Normal file
58
README.md
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
# PYQ clone
|
||||||
|
|
||||||
|
A working full-stack copy of the ZenithPrep PYQ practice app: build a paper from
|
||||||
|
JEE Main style questions, take it on a timed CBT screen with +4 / −1 marking,
|
||||||
|
then read the analysis and progress dashboard.
|
||||||
|
|
||||||
|
Rust + Axum + SQLite. Server-rendered HTML, vanilla JS, no frontend framework.
|
||||||
|
|
||||||
|
## Run
|
||||||
|
|
||||||
|
cargo run
|
||||||
|
|
||||||
|
Then open http://127.0.0.1:8080 and sign in with any name and email.
|
||||||
|
|
||||||
|
Environment:
|
||||||
|
|
||||||
|
| var | default | meaning |
|
||||||
|
|------------|--------------------|------------------------------|
|
||||||
|
| `PYQ_ADDR` | `127.0.0.1:8080` | listen address |
|
||||||
|
| `PYQ_DB` | `pyq.db` | SQLite file, created if absent |
|
||||||
|
| `PYQ_BASE` | empty | URL prefix when mounted in a subpath |
|
||||||
|
|
||||||
|
## Routes
|
||||||
|
|
||||||
|
| route | what it does |
|
||||||
|
|---------------------------|-----------------------------------------------|
|
||||||
|
| `GET /` | landing page with live bank counts |
|
||||||
|
| `GET /student`, `POST /student` | local sign-in, sets an HttpOnly session cookie |
|
||||||
|
| `GET /logout` | clears the session |
|
||||||
|
| `GET /build`, `POST /build` | chapter picker and paper generation |
|
||||||
|
| `POST /attempt/:paper` | starts or resumes an attempt |
|
||||||
|
| `GET /attempt/:paper` | the CBT screen |
|
||||||
|
| `POST /attempt/:id/answer` | JSON autosave of one answer |
|
||||||
|
| `POST /attempt/:id/submit` | grades and closes the paper |
|
||||||
|
| `GET /result/:attempt` | score, accuracy and per-question analysis |
|
||||||
|
| `GET /history` | every submitted paper |
|
||||||
|
| `GET /dashboard` | cross-paper trend, weak subtopics, coach notes |
|
||||||
|
| `GET /s/:token` | public paper view, never a score |
|
||||||
|
| `GET /api/attempt/:id/state` | autosaved answers as JSON |
|
||||||
|
|
||||||
|
## Marking
|
||||||
|
|
||||||
|
+4 for a correct answer, −1 for a wrong one, 0 for a blank. Numerical answers
|
||||||
|
compare against a 0.1 percent tolerance so `20.01` is accepted for `20`.
|
||||||
|
|
||||||
|
## Question selection
|
||||||
|
|
||||||
|
A paper is drawn from the chosen chapters, shuffled with a seed derived from the
|
||||||
|
request, and trimmed to the requested length. With "unseen questions only" the
|
||||||
|
query excludes anything already answered by that user, so the same question does
|
||||||
|
not come back by accident.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
cargo test
|
||||||
|
|
||||||
|
Covers the marking rules, numerical tolerance, and the urlencoded form decoding
|
||||||
|
that shapes the build endpoint.
|
||||||
13
content/privacy.html
Normal file
13
content/privacy.html
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
<p class="meta">What is stored, where it lives, and what you can do about it.</p>
|
||||||
|
<h3>What is stored</h3>
|
||||||
|
<p class="meta">Your name and email, the papers you build, your answers to each question, how long you spent on each one, and the score of every submitted paper.</p>
|
||||||
|
<h3>Where it lives</h3>
|
||||||
|
<p class="meta">Everything is written to a SQLite file on the machine running this service. Nothing is sent to a third party. There is no analytics script, no tracking pixel, and no external font request beyond the Google Fonts stylesheet used for typography.</p>
|
||||||
|
<h3>What is never collected</h3>
|
||||||
|
<p class="meta">No contacts, no location, no device identifiers, no advertising profile.</p>
|
||||||
|
<h3>Who can read it</h3>
|
||||||
|
<p class="meta">Anyone with access to the database file or to a signed-in browser session. Sessions are held in an HttpOnly cookie scoped to this site.</p>
|
||||||
|
<h3>Deleting your data</h3>
|
||||||
|
<p class="meta">Delete your account and every paper, answer and result attached to it by removing your row from the users table. Nothing is retained elsewhere, so that removal is complete.</p>
|
||||||
|
<h3>Questions bank</h3>
|
||||||
|
<p class="meta">Question content is study material held in the same database. It is not personal data and is not linked to you beyond the papers you build from it.</p>
|
||||||
13
content/terms.html
Normal file
13
content/terms.html
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
<p class="meta">This is a study tool built as a working copy of an existing practice site. Using it means you accept these terms.</p>
|
||||||
|
<h3>What the service does</h3>
|
||||||
|
<p class="meta">It builds practice papers from a bank of JEE Main style questions, runs them on a timed screen with +4 and −1 marking, and keeps your submitted papers so you can review results.</p>
|
||||||
|
<h3>Your account</h3>
|
||||||
|
<p class="meta">Sign-in is local to this installation. There is no password: anyone who knows the email can attach papers to that account. Do not use it for anything you would mind losing.</p>
|
||||||
|
<h3>Question accuracy</h3>
|
||||||
|
<p class="meta">Questions, answers and solutions are study material and may contain errors. Treat a wrong answer key as a bug, not a fact, and check anything you plan to rely on.</p>
|
||||||
|
<h3>Acceptable use</h3>
|
||||||
|
<p class="meta">No scraping at scale, no automated bulk paper generation, no attempts to reach other students' papers. Personal practice only.</p>
|
||||||
|
<h3>No warranty</h3>
|
||||||
|
<p class="meta">The service is provided as is, with no promise that it will be available or error free.</p>
|
||||||
|
<h3>Changes</h3>
|
||||||
|
<p class="meta">Terms can change as the tool does. The version in this file is the one that applies.</p>
|
||||||
13
run_server.sh
Executable file
13
run_server.sh
Executable file
|
|
@ -0,0 +1,13 @@
|
||||||
|
#!/bin/bash
|
||||||
|
# Starts the clone detached so it survives shell teardown. Usage: run_server.sh <port> <db>
|
||||||
|
set -e
|
||||||
|
DIR="/home/oiupoyt/my stuff yea/projects/zenith-pyq-clone"
|
||||||
|
PORT="${1:-8123}"
|
||||||
|
DB="${2:-/tmp/pyq_run.db}"
|
||||||
|
cd "$DIR"
|
||||||
|
rm -f "$DB" "$DB-wal" "$DB-shm"
|
||||||
|
PYQ_DB="$DB" PYQ_ADDR="127.0.0.1:$PORT" RUST_LOG=info \
|
||||||
|
setsid nohup ./target/debug/zenith-pyq-clone > "/tmp/pyq_server_$PORT.log" 2>&1 < /dev/null &
|
||||||
|
echo "started pid=$! port=$PORT db=$DB"
|
||||||
|
sleep 3
|
||||||
|
curl -s -o /dev/null -w "health=%{http_code}\n" "http://127.0.0.1:$PORT/"
|
||||||
242
src/analytics.rs
Normal file
242
src/analytics.rs
Normal file
|
|
@ -0,0 +1,242 @@
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use sqlx::{Row, SqlitePool};
|
||||||
|
|
||||||
|
use crate::exam::{self, Graded};
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct Bucket {
|
||||||
|
pub label: String,
|
||||||
|
pub right: i32,
|
||||||
|
pub wrong: i32,
|
||||||
|
pub skipped: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Bucket {
|
||||||
|
pub fn total(&self) -> i32 {
|
||||||
|
self.right + self.wrong + self.skipped
|
||||||
|
}
|
||||||
|
pub fn pct(&self) -> i32 {
|
||||||
|
if self.total() == 0 { 0 } else { (self.right as f64 / self.total() as f64 * 100.0).round() as i32 }
|
||||||
|
}
|
||||||
|
pub fn lost(&self) -> i32 {
|
||||||
|
self.wrong + self.skipped
|
||||||
|
}
|
||||||
|
pub fn status(&self) -> &'static str {
|
||||||
|
let t = self.total();
|
||||||
|
if t == 0 {
|
||||||
|
return "Good";
|
||||||
|
}
|
||||||
|
let lost = self.lost() as f64 / t as f64;
|
||||||
|
if lost >= 0.5 {
|
||||||
|
"Weak"
|
||||||
|
} else if lost >= 0.3 {
|
||||||
|
"Revise"
|
||||||
|
} else {
|
||||||
|
"Good"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub struct Analysis {
|
||||||
|
pub score: i32,
|
||||||
|
pub max_score: i32,
|
||||||
|
pub used_secs: i64,
|
||||||
|
pub right: i32,
|
||||||
|
pub wrong: i32,
|
||||||
|
pub skipped: i32,
|
||||||
|
pub by_subject: Vec<Bucket>,
|
||||||
|
pub by_difficulty: Vec<Bucket>,
|
||||||
|
pub by_topic: Vec<(Bucket, String)>,
|
||||||
|
pub mistakes: Vec<Graded>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn accuracy(right: i32, wrong: i32) -> i32 {
|
||||||
|
if right + wrong == 0 { 0 } else { (right as f64 / (right + wrong) as f64 * 100.0).round() as i32 }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn analyse(rows: &[Graded], score: i32, max_score: i32, used_secs: i64) -> Analysis {
|
||||||
|
let (mut right, mut wrong, mut skipped) = (0, 0, 0);
|
||||||
|
let mut subj: Vec<Bucket> = Vec::new();
|
||||||
|
let mut diff: Vec<Bucket> = Vec::new();
|
||||||
|
let mut topic: HashMap<(String, String), Bucket> = HashMap::new();
|
||||||
|
let mut mistakes: Vec<Graded> = Vec::new();
|
||||||
|
|
||||||
|
for r in rows {
|
||||||
|
let attempted = !r.selected.trim().is_empty();
|
||||||
|
if attempted && r.correct {
|
||||||
|
right += 1;
|
||||||
|
} else if attempted {
|
||||||
|
wrong += 1;
|
||||||
|
mistakes.push((*r).clone());
|
||||||
|
} else {
|
||||||
|
skipped += 1;
|
||||||
|
}
|
||||||
|
for (list, key) in [
|
||||||
|
(&mut subj, r.q.subject.clone()),
|
||||||
|
(&mut diff, r.q.difficulty.clone()),
|
||||||
|
] {
|
||||||
|
match list.iter_mut().find(|b| b.label == key) {
|
||||||
|
Some(b) => {
|
||||||
|
if attempted && r.correct { b.right += 1 } else if attempted { b.wrong += 1 } else { b.skipped += 1 }
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
let mut b = Bucket { label: key, right: 0, wrong: 0, skipped: 0 };
|
||||||
|
if attempted && r.correct { b.right = 1 } else if attempted { b.wrong = 1 } else { b.skipped = 1 }
|
||||||
|
list.push(b);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let key = (r.q.chapter.clone(), r.q.subtopic.clone());
|
||||||
|
let e = topic.entry(key).or_insert_with(|| Bucket { label: r.q.subtopic.clone(), right: 0, wrong: 0, skipped: 0 });
|
||||||
|
if attempted && r.correct { e.right += 1 } else if attempted { e.wrong += 1 } else { e.skipped += 1 }
|
||||||
|
}
|
||||||
|
subj.sort_by(|a, b| a.label.cmp(&b.label));
|
||||||
|
diff.sort_by_key(|b| match b.label.as_str() { "Easy" => 0, "Medium" => 1, _ => 2 });
|
||||||
|
let mut by_topic: Vec<(Bucket, String)> = topic.into_iter().map(|((_, s), b)| (b, s)).collect();
|
||||||
|
by_topic.sort_by_key(|(b, _)| (-b.lost(), b.label.clone()));
|
||||||
|
|
||||||
|
Analysis {
|
||||||
|
score,
|
||||||
|
max_score,
|
||||||
|
used_secs,
|
||||||
|
right,
|
||||||
|
wrong,
|
||||||
|
skipped,
|
||||||
|
by_subject: subj,
|
||||||
|
by_difficulty: diff,
|
||||||
|
by_topic,
|
||||||
|
mistakes,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct TestRow {
|
||||||
|
pub attempt_id: String,
|
||||||
|
pub started_at: DateTime<Utc>,
|
||||||
|
pub title: String,
|
||||||
|
pub score: i32,
|
||||||
|
pub max_score: i32,
|
||||||
|
pub used_secs: i64,
|
||||||
|
pub right: i32,
|
||||||
|
pub wrong: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn history(pool: &SqlitePool, uid: &str) -> anyhow::Result<Vec<TestRow>> {
|
||||||
|
let rows = sqlx::query(
|
||||||
|
"SELECT a.id, a.started_at, a.score, a.max_score, a.used_secs, p.title FROM attempts a
|
||||||
|
JOIN papers p ON p.id=a.paper_id WHERE a.user_id=? AND a.submitted_at IS NOT NULL ORDER BY a.submitted_at DESC",
|
||||||
|
)
|
||||||
|
.bind(uid)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await?;
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for r in &rows {
|
||||||
|
let aid: String = r.get("id");
|
||||||
|
let (g, right, wrong) = exam::history_counts(pool, &aid).await?;
|
||||||
|
out.push(TestRow {
|
||||||
|
attempt_id: aid,
|
||||||
|
started_at: r.get::<String, _>("started_at").parse().unwrap_or_else(|_| Utc::now()),
|
||||||
|
title: r.get("title"),
|
||||||
|
score: r.get("score"),
|
||||||
|
max_score: r.get("max_score"),
|
||||||
|
used_secs: r.get("used_secs"),
|
||||||
|
right,
|
||||||
|
wrong,
|
||||||
|
});
|
||||||
|
let _ = g;
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub struct Trend {
|
||||||
|
pub right: i32,
|
||||||
|
pub wrong: i32,
|
||||||
|
pub skipped: i32,
|
||||||
|
pub seen: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn weakest(pool: &SqlitePool, uid: &str) -> anyhow::Result<Vec<(Bucket, String, String)>> {
|
||||||
|
let rows = sqlx::query(
|
||||||
|
"SELECT c.name AS chapter, s.name AS subtopic, q.answer, a.selected, q.qtype
|
||||||
|
FROM answers a JOIN attempts at ON at.id=a.attempt_id JOIN paper_questions pq ON pq.paper_id=at.paper_id AND pq.question_id=a.question_id
|
||||||
|
JOIN questions q ON q.id=a.question_id JOIN chapters c ON c.id=q.chapter_id JOIN subtopics s ON s.id=q.subtopic_id
|
||||||
|
WHERE at.user_id=? AND at.submitted_at IS NOT NULL",
|
||||||
|
)
|
||||||
|
.bind(uid)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await?;
|
||||||
|
let mut m: HashMap<(String, String), Bucket> = HashMap::new();
|
||||||
|
for r in &rows {
|
||||||
|
let sel: String = r.get("selected");
|
||||||
|
let ans: String = r.get("answer");
|
||||||
|
let qtype: String = r.get("qtype");
|
||||||
|
let attempted = !sel.trim().is_empty();
|
||||||
|
let ok = match qtype.as_str() {
|
||||||
|
"NUM" => exam::numerical_ok(&sel, &ans),
|
||||||
|
_ => attempted && sel.trim().eq_ignore_ascii_case(ans.trim()),
|
||||||
|
};
|
||||||
|
let chapter: String = r.get("chapter");
|
||||||
|
let subtopic: String = r.get("subtopic");
|
||||||
|
let e = m.entry((chapter, subtopic.clone())).or_insert_with(|| Bucket { label: subtopic, right: 0, wrong: 0, skipped: 0 });
|
||||||
|
if attempted && ok { e.right += 1 } else if attempted { e.wrong += 1 } else { e.skipped += 1 }
|
||||||
|
}
|
||||||
|
let mut v: Vec<(Bucket, String, String)> = m.into_iter().map(|((c, s), b)| (b, c, s)).collect();
|
||||||
|
v.sort_by_key(|(b, _, _)| (-b.lost(), b.label.clone()));
|
||||||
|
v.truncate(8);
|
||||||
|
Ok(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn coach_notes(a: &Analysis, history: &[TestRow]) -> Vec<String> {
|
||||||
|
let mut notes = Vec::new();
|
||||||
|
if a.score == 0 && a.right == 0 {
|
||||||
|
notes.push("No question in this paper was answered correctly. Start with the chapters you just revised, one at a time, and use unseen-only so nothing is repeated.".into());
|
||||||
|
} else {
|
||||||
|
notes.push(format!(
|
||||||
|
"You scored {} of {} ({}% correct) with {} wrong and {} skipped in {}.",
|
||||||
|
a.score,
|
||||||
|
a.max_score,
|
||||||
|
accuracy(a.right, a.wrong),
|
||||||
|
a.wrong,
|
||||||
|
a.skipped,
|
||||||
|
fmt_dur(a.used_secs)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if let Some((w, name)) = a.by_topic.iter().find(|(b, _)| b.lost() > 0) {
|
||||||
|
notes.push(format!("Largest mark loss: {} ({} of {} questions lost). Revise this before another full paper.", name, w.lost(), w.total()));
|
||||||
|
}
|
||||||
|
if let Some(h) = a.by_difficulty.iter().find(|b| b.label == "Hard") {
|
||||||
|
if h.total() > 0 && h.pct() < 40 {
|
||||||
|
notes.push(format!("Hard questions: {}% correct. Spend a full sitting on hard-only papers before mixing again.", h.pct()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if history.len() >= 2 {
|
||||||
|
let first = history.last().unwrap();
|
||||||
|
let last = history.first().unwrap();
|
||||||
|
if last.max_score > 0 && first.max_score > 0 {
|
||||||
|
let p1 = first.score as f64 / first.max_score as f64 * 100.0;
|
||||||
|
let p2 = last.score as f64 / last.max_score as f64 * 100.0;
|
||||||
|
notes.push(format!(
|
||||||
|
"Across {} tests your score moved from {}% to {}%. {}",
|
||||||
|
history.len(),
|
||||||
|
p1.round() as i32,
|
||||||
|
p2.round() as i32,
|
||||||
|
if p2 >= p1 { "The trend is up." } else { "The trend is down, so the recent papers targeted something that needs another pass." }
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if a.skipped > a.right {
|
||||||
|
notes.push(format!("{} questions were left blank against {} answered right. Time management, not knowledge, is costing you marks here.", a.skipped, a.right));
|
||||||
|
}
|
||||||
|
notes
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn fmt_dur(s: i64) -> String {
|
||||||
|
format!("{}h {:02}m {:02}s", s / 3600, (s % 3600) / 60, s % 60)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn fmt_pct(score: i32, max: i32) -> i32 {
|
||||||
|
if max == 0 { 0 } else { (score as f64 / max as f64 * 100.0).round() as i32 }
|
||||||
|
}
|
||||||
150
src/auth.rs
Normal file
150
src/auth.rs
Normal file
|
|
@ -0,0 +1,150 @@
|
||||||
|
use axum::extract::{Form, State};
|
||||||
|
use axum::http::{HeaderMap, StatusCode};
|
||||||
|
use axum::response::{Html, IntoResponse};
|
||||||
|
use chrono::Utc;
|
||||||
|
use serde::Deserialize;
|
||||||
|
use sqlx::SqlitePool;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use crate::views;
|
||||||
|
|
||||||
|
pub const COOKIE: &str = "pyq_session";
|
||||||
|
|
||||||
|
pub struct Ctx {
|
||||||
|
pub pool: SqlitePool,
|
||||||
|
pub base: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Ctx {
|
||||||
|
/// Returns the signed-in user id and display name, or a redirect to sign-in.
|
||||||
|
pub async fn require_user(&self, h: &HeaderMap) -> Result<(String, String), axum::response::Response> {
|
||||||
|
match user_id(&self.pool, h).await {
|
||||||
|
Some(id) => {
|
||||||
|
let name: String = sqlx::query_scalar("SELECT name FROM users WHERE id=?")
|
||||||
|
.bind(&id)
|
||||||
|
.fetch_one(&self.pool)
|
||||||
|
.await
|
||||||
|
.unwrap_or_else(|_| "Student".into());
|
||||||
|
Ok((id, name))
|
||||||
|
}
|
||||||
|
None => Err(redirect(&format!("{}/student", self.base))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn user_id(pool: &SqlitePool, h: &HeaderMap) -> Option<String> {
|
||||||
|
let raw = h.get(COOKIE).and_then(|v| v.to_str().ok()).or_else(|| {
|
||||||
|
h.get(axum::http::header::COOKIE)
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
})?;
|
||||||
|
let mut token: Option<&str> = None;
|
||||||
|
for part in raw.split(';') {
|
||||||
|
if let Some((k, v)) = part.trim().split_once('=')
|
||||||
|
&& k == COOKIE
|
||||||
|
{
|
||||||
|
token = Some(v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let token = token?;
|
||||||
|
tracing::debug!("session token={token}");
|
||||||
|
match sqlx::query_scalar::<_, String>("SELECT user_id FROM sessions WHERE token=?")
|
||||||
|
.bind(token)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(found) => {
|
||||||
|
tracing::debug!("session lookup -> {:?}", found);
|
||||||
|
found
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("session lookup failed: {e}");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn redirect(to: &str) -> axum::response::Response {
|
||||||
|
axum::response::Response::builder()
|
||||||
|
.status(StatusCode::FOUND)
|
||||||
|
.header(axum::http::header::LOCATION, to)
|
||||||
|
.body(axum::body::Body::empty())
|
||||||
|
.expect("static redirect response")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn html_page(body: String, code: StatusCode) -> axum::response::Response {
|
||||||
|
(code, Html(body)).into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn student_post(State(ctx): State<Arc<Ctx>>, Form(f): Form<StudentForm>) -> axum::response::Response {
|
||||||
|
let name = f.name.trim();
|
||||||
|
let email = f.email.trim().to_lowercase();
|
||||||
|
let next = f
|
||||||
|
.next
|
||||||
|
.filter(|n| n.starts_with('/') && !n.contains(".."))
|
||||||
|
.unwrap_or_else(|| "/build".into());
|
||||||
|
|
||||||
|
if name.is_empty() || name.chars().count() > 80 {
|
||||||
|
return html_page(views::student(&ctx, &next, Some("Enter the name your account shows.")), StatusCode::BAD_REQUEST);
|
||||||
|
}
|
||||||
|
if !email.contains('@') || email.len() > 120 {
|
||||||
|
return html_page(views::student(&ctx, &next, Some("That email address does not look right.")), StatusCode::BAD_REQUEST);
|
||||||
|
}
|
||||||
|
|
||||||
|
let candidate = uuid::Uuid::new_v4().to_string();
|
||||||
|
sqlx::query("INSERT INTO users(id,name,email,created_at) VALUES(?,?,?,?) ON CONFLICT(email) DO UPDATE SET name=excluded.name")
|
||||||
|
.bind(&candidate)
|
||||||
|
.bind(name)
|
||||||
|
.bind(&email)
|
||||||
|
.bind(Utc::now().to_rfc3339())
|
||||||
|
.execute(&ctx.pool)
|
||||||
|
.await
|
||||||
|
.ok();
|
||||||
|
let uid: String = sqlx::query_scalar("SELECT id FROM users WHERE email=?")
|
||||||
|
.bind(&email)
|
||||||
|
.fetch_one(&ctx.pool)
|
||||||
|
.await
|
||||||
|
.unwrap_or(candidate);
|
||||||
|
|
||||||
|
let token = uuid::Uuid::new_v4().simple().to_string();
|
||||||
|
sqlx::query("INSERT INTO sessions(token,user_id,created_at) VALUES(?,?,?)")
|
||||||
|
.bind(&token)
|
||||||
|
.bind(&uid)
|
||||||
|
.bind(Utc::now().to_rfc3339())
|
||||||
|
.execute(&ctx.pool)
|
||||||
|
.await
|
||||||
|
.ok();
|
||||||
|
|
||||||
|
let cookie = format!("{COOKIE}={token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=2592000");
|
||||||
|
axum::response::Response::builder()
|
||||||
|
.status(StatusCode::FOUND)
|
||||||
|
.header(axum::http::header::LOCATION, format!("{}{}", ctx.base, next))
|
||||||
|
.header(axum::http::header::SET_COOKIE, cookie)
|
||||||
|
.body(axum::body::Body::empty())
|
||||||
|
.expect("static redirect response")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct StudentForm {
|
||||||
|
pub name: String,
|
||||||
|
pub email: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub next: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn logout(State(ctx): State<Arc<Ctx>>, h: HeaderMap) -> axum::response::Response {
|
||||||
|
if let Some(uid) = user_id(&ctx.pool, &h).await {
|
||||||
|
tracing::info!("sign out for {uid}");
|
||||||
|
}
|
||||||
|
if let Some(raw) = h.get(COOKIE).and_then(|c| c.to_str().ok())
|
||||||
|
&& let Some((_, v)) = raw.split_once('=')
|
||||||
|
{
|
||||||
|
let token = v.split(';').next().unwrap_or_default();
|
||||||
|
sqlx::query("DELETE FROM sessions WHERE token=?").bind(token).execute(&ctx.pool).await.ok();
|
||||||
|
}
|
||||||
|
let mut r = redirect(&format!("{}/", ctx.base));
|
||||||
|
r.headers_mut().insert(
|
||||||
|
axum::http::header::SET_COOKIE,
|
||||||
|
format!("{COOKIE}=; Path=/; HttpOnly; Max-Age=0").parse().expect("valid cookie"),
|
||||||
|
);
|
||||||
|
r
|
||||||
|
}
|
||||||
117
src/db.rs
Normal file
117
src/db.rs
Normal file
|
|
@ -0,0 +1,117 @@
|
||||||
|
use sqlx::SqlitePool;
|
||||||
|
|
||||||
|
pub async fn init_db(pool: &SqlitePool) -> anyhow::Result<()> {
|
||||||
|
sqlx::query(
|
||||||
|
"CREATE TABLE IF NOT EXISTS users(id TEXT PRIMARY KEY, name TEXT NOT NULL, email TEXT UNIQUE NOT NULL, created_at TEXT NOT NULL);
|
||||||
|
CREATE TABLE IF NOT EXISTS chapters(id INTEGER PRIMARY KEY, subject TEXT NOT NULL, name TEXT NOT NULL);
|
||||||
|
CREATE TABLE IF NOT EXISTS subtopics(id INTEGER PRIMARY KEY, chapter_id INTEGER NOT NULL REFERENCES chapters(id), name TEXT NOT NULL);
|
||||||
|
CREATE TABLE IF NOT EXISTS questions(id INTEGER PRIMARY KEY, subject TEXT NOT NULL, chapter_id INTEGER NOT NULL, subtopic_id INTEGER NOT NULL, qtype TEXT NOT NULL, stem TEXT NOT NULL, opt_a TEXT, opt_b TEXT, opt_c TEXT, opt_d TEXT, answer TEXT NOT NULL, difficulty TEXT NOT NULL, year INTEGER NOT NULL, shift TEXT NOT NULL, solution TEXT NOT NULL);
|
||||||
|
CREATE TABLE IF NOT EXISTS papers(id TEXT PRIMARY KEY, user_id TEXT NOT NULL, title TEXT NOT NULL, duration_min INTEGER NOT NULL, created_at TEXT NOT NULL, share_token TEXT NOT NULL);
|
||||||
|
CREATE TABLE IF NOT EXISTS paper_questions(paper_id TEXT NOT NULL, question_id INTEGER NOT NULL, ord INTEGER NOT NULL, PRIMARY KEY(paper_id, question_id));
|
||||||
|
CREATE TABLE IF NOT EXISTS attempts(id TEXT PRIMARY KEY, paper_id TEXT NOT NULL, user_id TEXT NOT NULL, started_at TEXT NOT NULL, submitted_at TEXT, score INTEGER DEFAULT 0, max_score INTEGER DEFAULT 0, used_secs INTEGER DEFAULT 0);
|
||||||
|
CREATE TABLE IF NOT EXISTS answers(attempt_id TEXT NOT NULL, question_id INTEGER NOT NULL, selected TEXT DEFAULT '', marked_review INTEGER DEFAULT 0, time_secs INTEGER DEFAULT 0, PRIMARY KEY(attempt_id, question_id));
|
||||||
|
CREATE TABLE IF NOT EXISTS mistake_tags(attempt_id TEXT NOT NULL, question_id INTEGER NOT NULL, tag TEXT NOT NULL, PRIMARY KEY(attempt_id, question_id, tag));
|
||||||
|
CREATE TABLE IF NOT EXISTS sessions(token TEXT PRIMARY KEY, user_id TEXT NOT NULL, created_at TEXT NOT NULL);
|
||||||
|
CREATE TABLE IF NOT EXISTS seen_questions(user_id TEXT NOT NULL, question_id INTEGER NOT NULL, PRIMARY KEY(user_id, question_id));",
|
||||||
|
)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
let (n,): (i64,) = sqlx::query_as("SELECT COUNT(*) FROM questions")
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await?;
|
||||||
|
if n == 0 {
|
||||||
|
seed(pool).await?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SeedQ<'a> {
|
||||||
|
subject: &'a str,
|
||||||
|
chapter: &'a str,
|
||||||
|
subtopic: &'a str,
|
||||||
|
qtype: &'a str,
|
||||||
|
stem: &'a str,
|
||||||
|
opts: [&'a str; 4],
|
||||||
|
answer: &'a str,
|
||||||
|
difficulty: &'a str,
|
||||||
|
year: i64,
|
||||||
|
shift: &'a str,
|
||||||
|
solution: &'a str,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn seed(pool: &SqlitePool) -> anyhow::Result<()> {
|
||||||
|
let chapters: Vec<(&str, &str, Vec<&str>)> = vec![
|
||||||
|
("Physics", "Mechanics", vec!["Kinematics", "Laws of Motion", "Work Energy Power"]),
|
||||||
|
("Physics", "Electromagnetism", vec!["Electrostatics", "Current Electricity"]),
|
||||||
|
("Physics", "Optics", vec!["Ray Optics", "Wave Optics"]),
|
||||||
|
("Chemistry", "Physical Chemistry", vec!["Mole Concept", "Chemical Kinetics"]),
|
||||||
|
("Chemistry", "Organic Chemistry", vec!["GOC", "Aldehydes and Ketones"]),
|
||||||
|
("Chemistry", "Inorganic Chemistry", vec!["Periodic Table", "Coordination Compounds"]),
|
||||||
|
("Maths", "Algebra", vec!["Quadratic Equations", "Matrices and Determinants"]),
|
||||||
|
("Maths", "Calculus", vec!["Limits and Continuity", "Definite Integration"]),
|
||||||
|
("Maths", "Geometry", vec!["Straight Lines", "Parabola"]),
|
||||||
|
];
|
||||||
|
for (subj, ch, subs) in &chapters {
|
||||||
|
use sqlx::Row;
|
||||||
|
let id: i64 = sqlx::query("INSERT INTO chapters(subject,name) VALUES(?,?) RETURNING id")
|
||||||
|
.bind(subj)
|
||||||
|
.bind(ch)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await?
|
||||||
|
.get(0);
|
||||||
|
for s in subs {
|
||||||
|
sqlx::query("INSERT INTO subtopics(chapter_id,name) VALUES(?,?)")
|
||||||
|
.bind(id)
|
||||||
|
.bind(s)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let qs: Vec<SeedQ> = vec![
|
||||||
|
SeedQ{subject:"Physics",chapter:"Mechanics",subtopic:"Kinematics",qtype:"MCQ",stem:"A particle starts from rest with constant acceleration 2 m/s2. Distance covered in the 5th second is:",opts:["9 m","8 m","10 m","5 m"],answer:"A",difficulty:"Easy",year:2024,shift:"S1",solution:"Sn = u + a(2n-1)/2 = 0 + 2(9)/2 = 9 m."},
|
||||||
|
SeedQ{subject:"Physics",chapter:"Mechanics",subtopic:"Kinematics",qtype:"MCQ",stem:"Velocity-time graph is a straight line parallel to time axis. The motion is:",opts:["uniform velocity","uniform acceleration","non-uniform velocity","at rest"],answer:"A",difficulty:"Easy",year:2023,shift:"S2",solution:"Slope zero means acceleration zero, velocity constant."},
|
||||||
|
SeedQ{subject:"Physics",chapter:"Mechanics",subtopic:"Laws of Motion",qtype:"MCQ",stem:"A 2 kg block on a frictionless surface is pulled by 10 N. Its acceleration is:",opts:["5 m/s2","20 m/s2","2 m/s2","0.2 m/s2"],answer:"A",difficulty:"Easy",year:2024,shift:"S1",solution:"a = F/m = 10/2 = 5 m/s2."},
|
||||||
|
SeedQ{subject:"Physics",chapter:"Mechanics",subtopic:"Work Energy Power",qtype:"NUM",stem:"A 1 kg mass drops from 20 m (g=10). Speed just before impact in m/s is:",opts:["","","",""],answer:"20",difficulty:"Medium",year:2022,shift:"S1",solution:"v = sqrt(2gh) = sqrt(400) = 20 m/s."},
|
||||||
|
SeedQ{subject:"Physics",chapter:"Electromagnetism",subtopic:"Electrostatics",qtype:"MCQ",stem:"Force between two point charges doubles the distance. The force becomes:",opts:["one-fourth","half","double","same"],answer:"A",difficulty:"Easy",year:2023,shift:"S1",solution:"Coulomb force scales as 1/r2."},
|
||||||
|
SeedQ{subject:"Physics",chapter:"Electromagnetism",subtopic:"Current Electricity",qtype:"MCQ",stem:"A wire of resistance R is stretched to double length. New resistance is:",opts:["4R","2R","R/2","R"],answer:"A",difficulty:"Medium",year:2024,shift:"S2",solution:"R = rho l/A; volume constant gives R proportional to l2, so 4R."},
|
||||||
|
SeedQ{subject:"Physics",chapter:"Optics",subtopic:"Ray Optics",qtype:"MCQ",stem:"Focal length of an equiconvex lens (n=1.5, R=20 cm) is:",opts:["20 cm","40 cm","10 cm","30 cm"],answer:"A",difficulty:"Medium",year:2022,shift:"S2",solution:"1/f = (n-1)(2/R) = 0.5(2/20) = 1/20."},
|
||||||
|
SeedQ{subject:"Physics",chapter:"Optics",subtopic:"Wave Optics",qtype:"MCQ",stem:"In YDSE, fringe width is proportional to:",opts:["wavelength","1/wavelength","screen distance squared","slit width"],answer:"A",difficulty:"Easy",year:2023,shift:"S1",solution:"beta = lambda D/d."},
|
||||||
|
SeedQ{subject:"Chemistry",chapter:"Physical Chemistry",subtopic:"Mole Concept",qtype:"MCQ",stem:"Moles in 4 g of NaOH (M=40) are:",opts:["0.1","0.4","1","4"],answer:"A",difficulty:"Easy",year:2024,shift:"S1",solution:"n = 4/40 = 0.1."},
|
||||||
|
SeedQ{subject:"Chemistry",chapter:"Physical Chemistry",subtopic:"Chemical Kinetics",qtype:"MCQ",stem:"For a first order reaction, t1/2 = 10 min. Rate constant k is:",opts:["0.0693 min-1","0.693 min-1","10 min-1","1 min-1"],answer:"A",difficulty:"Medium",year:2023,shift:"S2",solution:"k = 0.693/t1/2 = 0.0693 min-1."},
|
||||||
|
SeedQ{subject:"Chemistry",chapter:"Organic Chemistry",subtopic:"GOC",qtype:"MCQ",stem:"Most stable carbocation among: methyl, ethyl, isopropyl, tert-butyl:",opts:["tert-butyl","methyl","ethyl","isopropyl"],answer:"A",difficulty:"Easy",year:2022,shift:"S1",solution:"Hyperconjugation plus inductive effect: 3 degree most stable."},
|
||||||
|
SeedQ{subject:"Chemistry",chapter:"Organic Chemistry",subtopic:"Aldehydes and Ketones",qtype:"MCQ",stem:"Tollens reagent gives silver mirror with:",opts:["aldehydes","ketones","alkenes","ethers"],answer:"A",difficulty:"Easy",year:2024,shift:"S2",solution:"Aldehydes reduce Tollens; ketones do not."},
|
||||||
|
SeedQ{subject:"Chemistry",chapter:"Inorganic Chemistry",subtopic:"Periodic Table",qtype:"MCQ",stem:"Highest first ionisation enthalpy among second period:",opts:["Ne","Li","B","O"],answer:"A",difficulty:"Easy",year:2023,shift:"S1",solution:"Noble gas Ne has maximum IE1 in period 2."},
|
||||||
|
SeedQ{subject:"Chemistry",chapter:"Inorganic Chemistry",subtopic:"Coordination Compounds",qtype:"MCQ",stem:"Oxidation state of Fe in [Fe(CN)6]4- is:",opts:["+2","+3","+4","0"],answer:"A",difficulty:"Medium",year:2024,shift:"S1",solution:"x + 6(-1) = -4 gives x = +2."},
|
||||||
|
SeedQ{subject:"Maths",chapter:"Algebra",subtopic:"Quadratic Equations",qtype:"MCQ",stem:"Roots of x2 - 5x + 6 = 0 are:",opts:["2, 3","1, 6","-2, -3","5, 1"],answer:"A",difficulty:"Easy",year:2024,shift:"S1",solution:"Factors (x-2)(x-3) = 0."},
|
||||||
|
SeedQ{subject:"Maths",chapter:"Algebra",subtopic:"Matrices and Determinants",qtype:"NUM",stem:"Determinant of [[2,0],[0,3]] is:",opts:["","","",""],answer:"6",difficulty:"Easy",year:2023,shift:"S1",solution:"Diagonal product 2x3 = 6."},
|
||||||
|
SeedQ{subject:"Maths",chapter:"Calculus",subtopic:"Limits and Continuity",qtype:"MCQ",stem:"lim x->0 sin(x)/x equals:",opts:["1","0","infinity","-1"],answer:"A",difficulty:"Easy",year:2022,shift:"S2",solution:"Standard limit equals 1."},
|
||||||
|
SeedQ{subject:"Maths",chapter:"Calculus",subtopic:"Definite Integration",qtype:"MCQ",stem:"Integral 0 to pi of sin(x) dx equals:",opts:["2","0","1","pi"],answer:"A",difficulty:"Medium",year:2024,shift:"S2",solution:"[-cos x]0^pi = 1 + 1 = 2."},
|
||||||
|
SeedQ{subject:"Maths",chapter:"Geometry",subtopic:"Straight Lines",qtype:"MCQ",stem:"Slope of line 2x + 3y = 6 is:",opts:["-2/3","2/3","3/2","-3/2"],answer:"A",difficulty:"Easy",year:2023,shift:"S2",solution:"y = -2x/3 + 2, slope -2/3."},
|
||||||
|
SeedQ{subject:"Maths",chapter:"Geometry",subtopic:"Parabola",qtype:"MCQ",stem:"Focus of y2 = 8x is:",opts:["(2,0)","(0,2)","(4,0)","(8,0)"],answer:"A",difficulty:"Medium",year:2024,shift:"S1",solution:"4a = 8 gives a = 2, focus (a,0)."},
|
||||||
|
SeedQ{subject:"Physics",chapter:"Mechanics",subtopic:"Laws of Motion",qtype:"MCQ",stem:"A car rounds a curve at constant speed. Its acceleration is:",opts:["centripetal, nonzero","zero","tangential only","infinite"],answer:"A",difficulty:"Medium",year:2023,shift:"S2",solution:"Direction changes so velocity changes; centripetal acceleration nonzero."},
|
||||||
|
SeedQ{subject:"Chemistry",chapter:"Physical Chemistry",subtopic:"Mole Concept",qtype:"NUM",stem:"Molarity of 0.5 mol solute in 2 L solution is (mol/L):",opts:["","","",""],answer:"0.25",difficulty:"Easy",year:2022,shift:"S1",solution:"M = 0.5/2 = 0.25."},
|
||||||
|
SeedQ{subject:"Maths",chapter:"Algebra",subtopic:"Quadratic Equations",qtype:"MCQ",stem:"Discriminant of x2 + 4x + 4 is:",opts:["0","16","-16","4"],answer:"A",difficulty:"Easy",year:2023,shift:"S1",solution:"D = 16 - 16 = 0, equal roots."},
|
||||||
|
SeedQ{subject:"Maths",chapter:"Calculus",subtopic:"Definite Integration",qtype:"NUM",stem:"Integral 0 to 1 of 2x dx equals:",opts:["","","",""],answer:"1",difficulty:"Easy",year:2022,shift:"S1",solution:"[x2]0^1 = 1."},
|
||||||
|
];
|
||||||
|
use sqlx::Row;
|
||||||
|
for q in &qs {
|
||||||
|
let ch_id: i64 = sqlx::query("SELECT id FROM chapters WHERE subject=? AND name=?")
|
||||||
|
.bind(q.subject)
|
||||||
|
.bind(q.chapter)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await?
|
||||||
|
.get(0);
|
||||||
|
let st_id: i64 = sqlx::query("SELECT id FROM subtopics WHERE chapter_id=? AND name=?")
|
||||||
|
.bind(ch_id)
|
||||||
|
.bind(q.subtopic)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await?
|
||||||
|
.get(0);
|
||||||
|
sqlx::query("INSERT INTO questions(subject,chapter_id,subtopic_id,qtype,stem,opt_a,opt_b,opt_c,opt_d,answer,difficulty,year,shift,solution) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)")
|
||||||
|
.bind(q.subject).bind(ch_id).bind(st_id).bind(q.qtype).bind(q.stem)
|
||||||
|
.bind(q.opts[0]).bind(q.opts[1]).bind(q.opts[2]).bind(q.opts[3])
|
||||||
|
.bind(q.answer).bind(q.difficulty).bind(q.year).bind(q.shift).bind(q.solution)
|
||||||
|
.execute(pool).await?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
311
src/exam.rs
Normal file
311
src/exam.rs
Normal file
|
|
@ -0,0 +1,311 @@
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use chrono::Utc;
|
||||||
|
use serde::Deserialize;
|
||||||
|
use sqlx::{Row, SqlitePool};
|
||||||
|
|
||||||
|
pub const CORRECT: i32 = 4;
|
||||||
|
pub const NEGATIVE: i32 = -1;
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct Question {
|
||||||
|
pub id: i64,
|
||||||
|
pub subject: String,
|
||||||
|
pub chapter: String,
|
||||||
|
pub subtopic: String,
|
||||||
|
pub qtype: String,
|
||||||
|
pub stem: String,
|
||||||
|
pub opts: [String; 4],
|
||||||
|
pub answer: String,
|
||||||
|
pub difficulty: String,
|
||||||
|
pub year: i64,
|
||||||
|
pub shift: String,
|
||||||
|
pub solution: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn qrow(r: &sqlx::sqlite::SqliteRow) -> Question {
|
||||||
|
let g = |i: &str| -> String { r.try_get::<Option<String>, _>(i).ok().flatten().unwrap_or_default() };
|
||||||
|
Question {
|
||||||
|
id: r.get("id"),
|
||||||
|
subject: r.get("subject"),
|
||||||
|
chapter: g("chapter"),
|
||||||
|
subtopic: g("subtopic"),
|
||||||
|
qtype: r.get("qtype"),
|
||||||
|
stem: r.get("stem"),
|
||||||
|
opts: [g("opt_a"), g("opt_b"), g("opt_c"), g("opt_d")],
|
||||||
|
answer: r.get("answer"),
|
||||||
|
difficulty: r.get("difficulty"),
|
||||||
|
year: r.get("year"),
|
||||||
|
shift: r.get("shift"),
|
||||||
|
solution: g("solution"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `chapters` arrives comma-separated: a urlencoded body with repeated `chapters=1&chapters=4`
|
||||||
|
/// keys keeps only the last value, so the form posts one list field instead.
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct BuildForm {
|
||||||
|
#[serde(default)]
|
||||||
|
pub chapters: String,
|
||||||
|
pub count: Option<usize>,
|
||||||
|
pub duration: Option<i64>,
|
||||||
|
pub unseen_only: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BuildForm {
|
||||||
|
fn chapter_ids(&self) -> Vec<i64> {
|
||||||
|
self.chapters.split(',').filter_map(|c| c.trim().parse().ok()).collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn build_paper(pool: &SqlitePool, uid: &str, f: &BuildForm) -> anyhow::Result<(String, String)> {
|
||||||
|
let chapters = f.chapter_ids();
|
||||||
|
if chapters.is_empty() {
|
||||||
|
anyhow::bail!("Pick at least one chapter");
|
||||||
|
}
|
||||||
|
let count = f.count.unwrap_or(12).clamp(3, 45);
|
||||||
|
let duration = f.duration.unwrap_or(count as i64 * 2).clamp(5, 180);
|
||||||
|
let unseen = f.unseen_only.as_deref() == Some("on");
|
||||||
|
|
||||||
|
let mut ids: Vec<i64> = Vec::new();
|
||||||
|
for ch in &chapters {
|
||||||
|
// sqlx requires a static SQL string for queries that bind parameters; unseen-exclusion is a separate branch.
|
||||||
|
let rows = if unseen {
|
||||||
|
sqlx::query(
|
||||||
|
"SELECT q.id FROM questions q WHERE q.chapter_id=?\n AND q.id NOT IN (SELECT question_id FROM seen_questions WHERE user_id=?)",
|
||||||
|
)
|
||||||
|
.bind(ch)
|
||||||
|
.bind(uid)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await?
|
||||||
|
} else {
|
||||||
|
sqlx::query("SELECT q.id FROM questions q WHERE q.chapter_id=?")
|
||||||
|
.bind(ch)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await?
|
||||||
|
};
|
||||||
|
for r in rows {
|
||||||
|
ids.push(r.get::<i64, _>("id"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ids.is_empty() {
|
||||||
|
anyhow::bail!("No questions left for that selection. Turn off 'unseen only' to reuse questions.");
|
||||||
|
}
|
||||||
|
// Deterministic shuffle keyed on the request, so a reload rebuilds an equivalent paper.
|
||||||
|
let seed = uid.len() as u64 ^ chapters.iter().sum::<i64>() as u64 ^ count as u64;
|
||||||
|
shuffle(&mut ids, seed);
|
||||||
|
ids.truncate(count);
|
||||||
|
|
||||||
|
let paper = uuid::Uuid::new_v4().to_string();
|
||||||
|
let share = uuid::Uuid::new_v4().simple().to_string()[..10].to_string();
|
||||||
|
let title = format!("Mixed paper · {count} questions");
|
||||||
|
sqlx::query("INSERT INTO papers(id,user_id,title,duration_min,created_at,share_token) VALUES(?,?,?,?,?,?)")
|
||||||
|
.bind(&paper)
|
||||||
|
.bind(uid)
|
||||||
|
.bind(&title)
|
||||||
|
.bind(duration)
|
||||||
|
.bind(Utc::now().to_rfc3339())
|
||||||
|
.bind(&share)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
for (i, qid) in ids.iter().enumerate() {
|
||||||
|
sqlx::query("INSERT INTO paper_questions(paper_id,question_id,ord) VALUES(?,?,?)")
|
||||||
|
.bind(&paper)
|
||||||
|
.bind(qid)
|
||||||
|
.bind(i as i64)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
Ok((paper, share))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn shuffle(v: &mut Vec<i64>, mut seed: u64) {
|
||||||
|
if seed == 0 {
|
||||||
|
seed = 0x9e3779b97f4a7c15;
|
||||||
|
}
|
||||||
|
for i in (1..v.len()).rev() {
|
||||||
|
seed ^= seed << 13;
|
||||||
|
seed ^= seed >> 7;
|
||||||
|
seed ^= seed << 17;
|
||||||
|
let j = (seed % (i as u64 + 1)) as usize;
|
||||||
|
v.swap(i, j);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn numerical_ok(selected: &str, answer: &str) -> bool {
|
||||||
|
let s: f64 = match selected.trim().parse() {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(_) => return false,
|
||||||
|
};
|
||||||
|
let a: f64 = match answer.trim().parse() {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(_) => return false,
|
||||||
|
};
|
||||||
|
let tol = a.abs() * 1e-3 + 1e-6;
|
||||||
|
(s - a).abs() <= tol
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_correct(q: &Question, selected: &str) -> bool {
|
||||||
|
if selected.trim().is_empty() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if q.qtype == "NUM" {
|
||||||
|
numerical_ok(selected, &q.answer)
|
||||||
|
} else {
|
||||||
|
selected.trim().eq_ignore_ascii_case(q.answer.trim())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn start_attempt(pool: &SqlitePool, uid: &str, paper: &str) -> anyhow::Result<String> {
|
||||||
|
let existing: Option<String> =
|
||||||
|
sqlx::query_scalar("SELECT a.id FROM attempts a WHERE a.paper_id=? AND a.user_id=? AND a.submitted_at IS NULL")
|
||||||
|
.bind(paper)
|
||||||
|
.bind(uid)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await?;
|
||||||
|
if let Some(id) = existing {
|
||||||
|
return Ok(id);
|
||||||
|
}
|
||||||
|
let id = uuid::Uuid::new_v4().to_string();
|
||||||
|
sqlx::query("INSERT INTO attempts(id,paper_id,user_id,started_at) VALUES(?,?,?,?)")
|
||||||
|
.bind(&id)
|
||||||
|
.bind(paper)
|
||||||
|
.bind(uid)
|
||||||
|
.bind(Utc::now().to_rfc3339())
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
let qids: Vec<i64> = sqlx::query_scalar("SELECT question_id FROM paper_questions WHERE paper_id=? ORDER BY ord")
|
||||||
|
.bind(paper)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await?;
|
||||||
|
for qid in &qids {
|
||||||
|
sqlx::query("INSERT INTO answers(attempt_id,question_id) VALUES(?,?)")
|
||||||
|
.bind(&id)
|
||||||
|
.bind(qid)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
Ok(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct AnswerIn {
|
||||||
|
pub question_id: i64,
|
||||||
|
pub selected: String,
|
||||||
|
pub marked_review: Option<bool>,
|
||||||
|
pub time_secs: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct SaveIn {
|
||||||
|
pub answers: Vec<AnswerIn>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn save_answers(pool: &SqlitePool, attempt: &str, uid: &str, body: &SaveIn) -> anyhow::Result<()> {
|
||||||
|
let ok: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM attempts WHERE id=? AND user_id=?")
|
||||||
|
.bind(attempt)
|
||||||
|
.bind(uid)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await?;
|
||||||
|
if ok == 0 {
|
||||||
|
anyhow::bail!("Attempt not found");
|
||||||
|
}
|
||||||
|
for a in &body.answers {
|
||||||
|
sqlx::query("UPDATE answers SET selected=?, marked_review=?, time_secs=? WHERE attempt_id=? AND question_id=?")
|
||||||
|
.bind(&a.selected)
|
||||||
|
.bind(a.marked_review.unwrap_or(false) as i64)
|
||||||
|
.bind(a.time_secs.unwrap_or(0).clamp(0, 7200))
|
||||||
|
.bind(attempt)
|
||||||
|
.bind(a.question_id)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn paper_questions(pool: &SqlitePool, paper: &str) -> anyhow::Result<Vec<Question>> {
|
||||||
|
let rows = sqlx::query(
|
||||||
|
"SELECT q.*, c.name AS chapter, s.name AS subtopic FROM questions q
|
||||||
|
JOIN chapters c ON c.id=q.chapter_id JOIN subtopics s ON s.id=q.subtopic_id
|
||||||
|
JOIN paper_questions pq ON pq.question_id=q.id WHERE pq.paper_id=? ORDER BY pq.ord",
|
||||||
|
)
|
||||||
|
.bind(paper)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(rows.iter().map(qrow).collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn answers_map(pool: &SqlitePool, attempt: &str) -> anyhow::Result<HashMap<i64, (String, bool, i64)>> {
|
||||||
|
let rows = sqlx::query("SELECT question_id, selected, marked_review, time_secs FROM answers WHERE attempt_id=?")
|
||||||
|
.bind(attempt)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(rows
|
||||||
|
.iter()
|
||||||
|
.map(|r| (r.get::<i64, _>("question_id"), (r.get::<String, _>("selected"), r.get::<i64, _>("marked_review") != 0, r.get::<i64, _>("time_secs"))))
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn history_counts(pool: &SqlitePool, attempt: &str) -> anyhow::Result<(usize, i32, i32)> {
|
||||||
|
let (rows, _, _, _) = grade(pool, attempt).await?;
|
||||||
|
let right = rows.iter().filter(|r| !r.selected.trim().is_empty() && r.correct).count();
|
||||||
|
let wrong = rows.iter().filter(|r| !r.selected.trim().is_empty() && !r.correct).count();
|
||||||
|
Ok((rows.len(), right as i32, wrong as i32))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct Graded {
|
||||||
|
pub q: Question,
|
||||||
|
pub selected: String,
|
||||||
|
pub correct: bool,
|
||||||
|
pub marks: i32,
|
||||||
|
pub time_secs: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn grade(pool: &SqlitePool, attempt: &str) -> anyhow::Result<(Vec<Graded>, i32, i32, i64)> {
|
||||||
|
let paper: String = sqlx::query_scalar("SELECT paper_id FROM attempts WHERE id=?").bind(attempt).fetch_one(pool).await?;
|
||||||
|
let qs = paper_questions(pool, &paper).await?;
|
||||||
|
let amap = answers_map(pool, attempt).await?;
|
||||||
|
let mut out = Vec::new();
|
||||||
|
let (mut score, mut max, mut used) = (0i32, 0i32, 0i64);
|
||||||
|
for q in qs {
|
||||||
|
let (sel, _rev, secs) = amap.get(&q.id).cloned().unwrap_or_default();
|
||||||
|
let attempted = !sel.trim().is_empty();
|
||||||
|
let correct = is_correct(&q, &sel);
|
||||||
|
let marks = if !attempted {
|
||||||
|
0
|
||||||
|
} else if correct {
|
||||||
|
CORRECT
|
||||||
|
} else {
|
||||||
|
NEGATIVE
|
||||||
|
};
|
||||||
|
score += marks;
|
||||||
|
max += CORRECT;
|
||||||
|
used += secs;
|
||||||
|
out.push(Graded { q, selected: sel, correct, marks, time_secs: secs });
|
||||||
|
}
|
||||||
|
Ok((out, score, max, used))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn finalize(pool: &SqlitePool, attempt: &str) -> anyhow::Result<(i32, i32, i64)> {
|
||||||
|
let (rows, score, max, used) = grade(pool, attempt).await?;
|
||||||
|
let uid: String = sqlx::query_scalar("SELECT user_id FROM attempts WHERE id=?").bind(attempt).fetch_one(pool).await?;
|
||||||
|
sqlx::query("UPDATE attempts SET submitted_at=?, score=?, max_score=?, used_secs=? WHERE id=?")
|
||||||
|
.bind(Utc::now().to_rfc3339())
|
||||||
|
.bind(score)
|
||||||
|
.bind(max)
|
||||||
|
.bind(used)
|
||||||
|
.bind(attempt)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
for r in &rows {
|
||||||
|
if !r.selected.trim().is_empty() {
|
||||||
|
sqlx::query("INSERT OR IGNORE INTO seen_questions(user_id,question_id) VALUES(?,?)")
|
||||||
|
.bind(&uid)
|
||||||
|
.bind(r.q.id)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok((score, max, used))
|
||||||
|
}
|
||||||
393
src/main.rs
Normal file
393
src/main.rs
Normal file
|
|
@ -0,0 +1,393 @@
|
||||||
|
mod analytics;
|
||||||
|
mod auth;
|
||||||
|
mod db;
|
||||||
|
mod exam;
|
||||||
|
mod views;
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use axum::extract::{Path, Query, State};
|
||||||
|
use axum::http::{HeaderMap, StatusCode};
|
||||||
|
use axum::response::{Html, IntoResponse, Redirect};
|
||||||
|
use axum::routing::{get, post};
|
||||||
|
use axum::{Form, Json, Router};
|
||||||
|
use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
|
||||||
|
use std::str::FromStr;
|
||||||
|
use axum::routing::get_service;
|
||||||
|
use tower_http::services::ServeDir;
|
||||||
|
|
||||||
|
use analytics::{analyse, coach_notes};
|
||||||
|
use auth::Ctx;
|
||||||
|
use exam::{BuildForm, SaveIn};
|
||||||
|
|
||||||
|
type CtxArc = Arc<Ctx>;
|
||||||
|
|
||||||
|
fn hx(body: String, code: StatusCode) -> impl IntoResponse {
|
||||||
|
(code, Html(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> anyhow::Result<()> {
|
||||||
|
tracing_subscriber::fmt().with_max_level(tracing::Level::DEBUG).init();
|
||||||
|
let db_path = std::env::var("PYQ_DB").unwrap_or_else(|_| "pyq.db".into());
|
||||||
|
let base = std::env::var("PYQ_BASE").unwrap_or_else(|_| String::new());
|
||||||
|
let addr = std::env::var("PYQ_ADDR").unwrap_or_else(|_| "127.0.0.1:8080".into());
|
||||||
|
let opts = SqliteConnectOptions::from_str(&format!("sqlite://{db_path}?mode=rwc"))?
|
||||||
|
.create_if_missing(true)
|
||||||
|
.busy_timeout(std::time::Duration::from_secs(5));
|
||||||
|
let pool = SqlitePoolOptions::new()
|
||||||
|
.max_connections(8)
|
||||||
|
.connect_with(opts)
|
||||||
|
.await?;
|
||||||
|
sqlx::query("PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON;").execute(&pool).await?;
|
||||||
|
db::init_db(&pool).await?;
|
||||||
|
let ctx = Arc::new(Ctx { pool, base: base.clone() });
|
||||||
|
let static_dir = format!("{}/static", std::env::current_dir()?.display());
|
||||||
|
|
||||||
|
let app = Router::new()
|
||||||
|
.route("/", get(home))
|
||||||
|
.route("/student", get(student_get).post(student_post))
|
||||||
|
.route("/logout", get(auth::logout))
|
||||||
|
.route("/terms", get(terms))
|
||||||
|
.route("/privacy", get(privacy))
|
||||||
|
.route("/build", get(build_page).post(build_post))
|
||||||
|
.route("/attempt/{paper}", get(attempt_page).post(start_attempt))
|
||||||
|
.route("/attempt/{attempt}/answer", post(save_answers))
|
||||||
|
.route("/attempt/{attempt}/submit", post(submit))
|
||||||
|
.route("/result/{attempt}", get(result_page))
|
||||||
|
.route("/history", get(history_page))
|
||||||
|
.route("/dashboard", get(dashboard_page))
|
||||||
|
.route("/s/{token}", get(shared_paper))
|
||||||
|
.route("/api/attempt/{attempt}/state", get(attempt_state))
|
||||||
|
.route("/api/attempt/{attempt}/submit", post(submit_api))
|
||||||
|
.nest_service("/static", get_service(ServeDir::new(static_dir)))
|
||||||
|
.with_state(ctx);
|
||||||
|
|
||||||
|
let listener = tokio::net::TcpListener::bind(&addr).await?;
|
||||||
|
tracing::info!("pyq clone listening on {addr} (base '{base}')");
|
||||||
|
axum::serve(listener, app).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn home(State(ctx): State<CtxArc>) -> impl IntoResponse {
|
||||||
|
let c: (i64, i64, i64, i64, i64) = sqlx::query_as(
|
||||||
|
"SELECT (SELECT COUNT(*) FROM questions),(SELECT COUNT(DISTINCT subject||'-'||year||'-'||shift) FROM questions),(SELECT COUNT(*) FROM subtopics),(SELECT COUNT(DISTINCT subject) FROM questions),(SELECT COUNT(*) FROM users)",
|
||||||
|
)
|
||||||
|
.fetch_one(&ctx.pool)
|
||||||
|
.await
|
||||||
|
.unwrap_or((0, 0, 0, 0, 0));
|
||||||
|
hx(views::landing(&ctx, c.0, c.1, c.2, c.3, c.4, false), StatusCode::OK)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn terms(State(ctx): State<CtxArc>) -> impl IntoResponse {
|
||||||
|
hx(views::legal(&ctx, "Terms of use", TERMS), StatusCode::OK)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn privacy(State(ctx): State<CtxArc>) -> impl IntoResponse {
|
||||||
|
hx(views::legal(&ctx, "Privacy policy", PRIVACY), StatusCode::OK)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn student_get(State(ctx): State<CtxArc>, h: HeaderMap, q: Query<std::collections::HashMap<String, String>>) -> axum::response::Response {
|
||||||
|
let next = q.get("next").cloned().unwrap_or_else(|| "/build".into());
|
||||||
|
if auth::user_id(&ctx.pool, &h).await.is_some() {
|
||||||
|
return Redirect::to(&format!("{}{}", ctx.base, next)).into_response();
|
||||||
|
}
|
||||||
|
hx(views::student(&ctx, &next, None), StatusCode::OK).into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn student_post(State(ctx): State<CtxArc>, Form(f): Form<auth::StudentForm>) -> axum::response::Response {
|
||||||
|
auth::student_post(State(ctx), axum::Form(f)).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn build_page(State(ctx): State<CtxArc>, h: HeaderMap) -> axum::response::Response {
|
||||||
|
let (_uid, name) = match ctx.require_user(&h).await {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(r) => return r,
|
||||||
|
};
|
||||||
|
use sqlx::Row;
|
||||||
|
let rows = sqlx::query(
|
||||||
|
"SELECT c.id, c.subject, c.name, s.name AS sub, (SELECT COUNT(*) FROM questions q WHERE q.chapter_id=c.id) AS n
|
||||||
|
FROM chapters c JOIN subtopics s ON s.chapter_id=c.id ORDER BY c.subject, c.name, s.name",
|
||||||
|
)
|
||||||
|
.fetch_all(&ctx.pool)
|
||||||
|
.await
|
||||||
|
.unwrap_or_default();
|
||||||
|
let mut blocks: Vec<views::SubjectBlock> = Vec::new();
|
||||||
|
for r in &rows {
|
||||||
|
let subject: String = r.get("subject");
|
||||||
|
let id: i64 = r.get("id");
|
||||||
|
let cname: String = r.get("name");
|
||||||
|
let sub: String = r.get("sub");
|
||||||
|
let n: i64 = r.get("n");
|
||||||
|
if !blocks.iter().any(|b| b.subject == subject) {
|
||||||
|
blocks.push(views::SubjectBlock { subject: subject.clone(), chapters: Vec::new() });
|
||||||
|
}
|
||||||
|
let blk = blocks.iter_mut().find(|b| b.subject == subject).unwrap();
|
||||||
|
match blk.chapters.iter_mut().find(|c| c.id == id) {
|
||||||
|
Some(c) => {
|
||||||
|
c.subtopics.push(sub);
|
||||||
|
c.count = n;
|
||||||
|
}
|
||||||
|
None => blk.chapters.push(views::ChapterBlock { id, name: cname, subtopics: vec![sub], count: n }),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let available: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM questions").fetch_one(&ctx.pool).await.unwrap_or(0);
|
||||||
|
let form = BuildForm { chapters: String::new(), count: Some(12), duration: Some(24), unseen_only: Some("on".into()) };
|
||||||
|
hx(views::build(&ctx, &name, blocks, &form, available, None), StatusCode::OK).into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn build_post(State(ctx): State<CtxArc>, h: HeaderMap, Form(f): Form<BuildForm>) -> axum::response::Response {
|
||||||
|
let (uid, _name) = match ctx.require_user(&h).await {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(r) => return r,
|
||||||
|
};
|
||||||
|
match exam::build_paper(&ctx.pool, &uid, &f).await {
|
||||||
|
Ok((paper, _share)) => Redirect::to(&format!("{}/attempt/{paper}", ctx.base)).into_response(),
|
||||||
|
Err(e) => {
|
||||||
|
use sqlx::Row;
|
||||||
|
let rows = sqlx::query(
|
||||||
|
"SELECT c.id, c.subject, c.name, s.name AS sub, (SELECT COUNT(*) FROM questions q WHERE q.chapter_id=c.id) AS n
|
||||||
|
FROM chapters c JOIN subtopics s ON s.chapter_id=c.id ORDER BY c.subject, c.name, s.name",
|
||||||
|
)
|
||||||
|
.fetch_all(&ctx.pool)
|
||||||
|
.await
|
||||||
|
.unwrap_or_default();
|
||||||
|
let mut blocks: Vec<views::SubjectBlock> = Vec::new();
|
||||||
|
for r in &rows {
|
||||||
|
let subject: String = r.get("subject");
|
||||||
|
let id: i64 = r.get("id");
|
||||||
|
let cname: String = r.get("name");
|
||||||
|
let sub: String = r.get("sub");
|
||||||
|
let n: i64 = r.get("n");
|
||||||
|
if !blocks.iter().any(|b| b.subject == subject) {
|
||||||
|
blocks.push(views::SubjectBlock { subject: subject.clone(), chapters: Vec::new() });
|
||||||
|
}
|
||||||
|
let blk = blocks.iter_mut().find(|b| b.subject == subject).unwrap();
|
||||||
|
match blk.chapters.iter_mut().find(|c| c.id == id) {
|
||||||
|
Some(c) => {
|
||||||
|
c.subtopics.push(sub);
|
||||||
|
c.count = n;
|
||||||
|
}
|
||||||
|
None => blk.chapters.push(views::ChapterBlock { id, name: cname, subtopics: vec![sub], count: n }),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let available: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM questions").fetch_one(&ctx.pool).await.unwrap_or(0);
|
||||||
|
let msg = e.to_string();
|
||||||
|
hx(views::build(&ctx, &_name, blocks, &f, available, Some(&msg)), StatusCode::OK).into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn start_attempt(State(ctx): State<CtxArc>, h: HeaderMap, Path(paper): Path<String>) -> axum::response::Response {
|
||||||
|
let (uid, _) = match ctx.require_user(&h).await {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(r) => return r,
|
||||||
|
};
|
||||||
|
let exists: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM papers WHERE id=? AND user_id=?")
|
||||||
|
.bind(&paper)
|
||||||
|
.bind(&uid)
|
||||||
|
.fetch_one(&ctx.pool)
|
||||||
|
.await
|
||||||
|
.unwrap_or(0);
|
||||||
|
if exists == 0 {
|
||||||
|
return hx(views::error(&ctx, 404, ""), StatusCode::NOT_FOUND).into_response();
|
||||||
|
}
|
||||||
|
match exam::start_attempt(&ctx.pool, &uid, &paper).await {
|
||||||
|
Ok(a) => Redirect::to(&format!("{}/attempt/{paper}?run={a}", ctx.base)).into_response(),
|
||||||
|
Err(e) => {
|
||||||
|
let m = e.to_string();
|
||||||
|
hx(views::error(&ctx, 500, &m), StatusCode::INTERNAL_SERVER_ERROR).into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn attempt_page(State(ctx): State<CtxArc>, h: HeaderMap, Path(paper): Path<String>, q: Query<std::collections::HashMap<String, String>>) -> axum::response::Response {
|
||||||
|
let (uid, name) = match ctx.require_user(&h).await {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(r) => return r,
|
||||||
|
};
|
||||||
|
let attempt_id = match q.get("run") {
|
||||||
|
Some(a) => a.clone(),
|
||||||
|
None => match exam::start_attempt(&ctx.pool, &uid, &paper).await {
|
||||||
|
Ok(a) => a,
|
||||||
|
Err(_) => return hx(views::error(&ctx, 404, ""), StatusCode::NOT_FOUND).into_response(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let owned: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM attempts WHERE id=? AND user_id=? AND paper_id=?")
|
||||||
|
.bind(&attempt_id)
|
||||||
|
.bind(&uid)
|
||||||
|
.bind(&paper)
|
||||||
|
.fetch_one(&ctx.pool)
|
||||||
|
.await
|
||||||
|
.unwrap_or(0);
|
||||||
|
if owned == 0 {
|
||||||
|
return hx(views::error(&ctx, 404, ""), StatusCode::NOT_FOUND).into_response();
|
||||||
|
}
|
||||||
|
let questions = match exam::paper_questions(&ctx.pool, &paper).await {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(_) => return hx(views::error(&ctx, 500, ""), StatusCode::INTERNAL_SERVER_ERROR).into_response(),
|
||||||
|
};
|
||||||
|
let title: String = sqlx::query_scalar("SELECT title FROM papers WHERE id=?").bind(&paper).fetch_one(&ctx.pool).await.unwrap_or_else(|_| "Paper".into());
|
||||||
|
let duration: i64 = sqlx::query_scalar("SELECT duration_min FROM papers WHERE id=?").bind(&paper).fetch_one(&ctx.pool).await.unwrap_or(30);
|
||||||
|
let amap = exam::answers_map(&ctx.pool, &attempt_id).await.unwrap_or_default();
|
||||||
|
let answers: Vec<(i64, String, bool)> = questions
|
||||||
|
.iter()
|
||||||
|
.map(|q| {
|
||||||
|
let (sel, rev, _) = amap.get(&q.id).cloned().unwrap_or_default();
|
||||||
|
(q.id, sel, rev)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
hx(views::attempt(&ctx, &name, &attempt_id, &questions, &answers, duration, &title), StatusCode::OK).into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn save_answers(State(ctx): State<CtxArc>, h: HeaderMap, Path(attempt): Path<String>, Json(body): Json<SaveIn>) -> impl IntoResponse {
|
||||||
|
let uid = match auth::user_id(&ctx.pool, &h).await {
|
||||||
|
Some(u) => u,
|
||||||
|
None => return hx("{\"error\":\"sign in first\"}".into(), StatusCode::UNAUTHORIZED),
|
||||||
|
};
|
||||||
|
match exam::save_answers(&ctx.pool, &attempt, &uid, &body).await {
|
||||||
|
Ok(_) => hx("{\"ok\":true}".into(), StatusCode::OK),
|
||||||
|
Err(e) => {
|
||||||
|
let m = e.to_string();
|
||||||
|
hx(format!("{{\"error\":\"{m}\"}}"), StatusCode::NOT_FOUND)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn attempt_state(State(ctx): State<CtxArc>, h: HeaderMap, Path(attempt): Path<String>) -> impl IntoResponse {
|
||||||
|
let uid = match auth::user_id(&ctx.pool, &h).await {
|
||||||
|
Some(u) => u,
|
||||||
|
None => return hx("{\"error\":\"sign in first\"}".into(), StatusCode::UNAUTHORIZED),
|
||||||
|
};
|
||||||
|
let n: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM attempts WHERE id=? AND user_id=?")
|
||||||
|
.bind(&attempt)
|
||||||
|
.bind(&uid)
|
||||||
|
.fetch_one(&ctx.pool)
|
||||||
|
.await
|
||||||
|
.unwrap_or(0);
|
||||||
|
if n == 0 {
|
||||||
|
return hx("{\"error\":\"not found\"}".into(), StatusCode::NOT_FOUND);
|
||||||
|
}
|
||||||
|
let amap = exam::answers_map(&ctx.pool, &attempt).await.unwrap_or_default();
|
||||||
|
let items: Vec<serde_json::Value> = amap
|
||||||
|
.iter()
|
||||||
|
.map(|(qid, (sel, rev, secs))| serde_json::json!({"question_id": qid, "selected": sel, "marked_review": rev, "time_secs": secs}))
|
||||||
|
.collect();
|
||||||
|
hx(serde_json::json!({"attempt": attempt, "answers": items}).to_string(), StatusCode::OK)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn submit(State(ctx): State<CtxArc>, h: HeaderMap, Path(attempt): Path<String>) -> axum::response::Response {
|
||||||
|
let (uid, _) = match ctx.require_user(&h).await {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(r) => return r,
|
||||||
|
};
|
||||||
|
let owned: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM attempts WHERE id=? AND user_id=?").bind(&attempt).bind(&uid).fetch_one(&ctx.pool).await.unwrap_or(0);
|
||||||
|
if owned == 0 {
|
||||||
|
return hx(views::error(&ctx, 404, ""), StatusCode::NOT_FOUND).into_response();
|
||||||
|
}
|
||||||
|
match exam::finalize(&ctx.pool, &attempt).await {
|
||||||
|
Ok(_) => Redirect::to(&format!("{}/result/{attempt}", ctx.base)).into_response(),
|
||||||
|
Err(e) => {
|
||||||
|
let m = e.to_string();
|
||||||
|
hx(views::error(&ctx, 500, &m), StatusCode::INTERNAL_SERVER_ERROR).into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn submit_api(State(ctx): State<CtxArc>, h: HeaderMap, Path(attempt): Path<String>) -> impl IntoResponse {
|
||||||
|
let uid = match auth::user_id(&ctx.pool, &h).await {
|
||||||
|
Some(u) => u,
|
||||||
|
None => return hx("{\"error\":\"sign in first\"}".into(), StatusCode::UNAUTHORIZED),
|
||||||
|
};
|
||||||
|
let owned: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM attempts WHERE id=? AND user_id=?").bind(&attempt).bind(&uid).fetch_one(&ctx.pool).await.unwrap_or(0);
|
||||||
|
if owned == 0 {
|
||||||
|
return hx("{\"error\":\"not found\"}".into(), StatusCode::NOT_FOUND);
|
||||||
|
}
|
||||||
|
match exam::finalize(&ctx.pool, &attempt).await {
|
||||||
|
Ok((score, max, used)) => hx(serde_json::json!({"score": score, "max": max, "used_secs": used, "result_url": format!("{}/result/{attempt}", ctx.base)}).to_string(), StatusCode::OK),
|
||||||
|
Err(e) => {
|
||||||
|
let m = e.to_string();
|
||||||
|
hx(format!("{{\"error\":\"{m}\"}}"), StatusCode::INTERNAL_SERVER_ERROR)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn result_page(State(ctx): State<CtxArc>, h: HeaderMap, Path(attempt): Path<String>) -> axum::response::Response {
|
||||||
|
let (uid, name) = match ctx.require_user(&h).await {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(r) => return r,
|
||||||
|
};
|
||||||
|
let owned: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM attempts WHERE id=? AND user_id=? AND submitted_at IS NOT NULL").bind(&attempt).bind(&uid).fetch_one(&ctx.pool).await.unwrap_or(0);
|
||||||
|
if owned == 0 {
|
||||||
|
return hx(views::error(&ctx, 404, ""), StatusCode::NOT_FOUND).into_response();
|
||||||
|
}
|
||||||
|
let (rows, score, max, used) = match exam::grade(&ctx.pool, &attempt).await {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(e) => {
|
||||||
|
let m = e.to_string();
|
||||||
|
return hx(views::error(&ctx, 500, &m), StatusCode::INTERNAL_SERVER_ERROR).into_response();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let a = analyse(&rows, score, max, used);
|
||||||
|
let rv: Vec<views::RowView> = rows
|
||||||
|
.iter()
|
||||||
|
.map(|r| views::RowView { q: &r.q, selected: &r.selected, marks: r.marks, correct: r.correct, time_secs: r.time_secs })
|
||||||
|
.collect();
|
||||||
|
hx(views::result(&ctx, &name, &attempt, &a, &rv), StatusCode::OK).into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn history_page(State(ctx): State<CtxArc>, h: HeaderMap) -> axum::response::Response {
|
||||||
|
let (uid, name) = match ctx.require_user(&h).await {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(r) => return r,
|
||||||
|
};
|
||||||
|
let tests = analytics::history(&ctx.pool, &uid).await.unwrap_or_default();
|
||||||
|
hx(views::history(&ctx, &name, &tests), StatusCode::OK).into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn dashboard_page(State(ctx): State<CtxArc>, h: HeaderMap) -> axum::response::Response {
|
||||||
|
let (uid, name) = match ctx.require_user(&h).await {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(r) => return r,
|
||||||
|
};
|
||||||
|
let tests = analytics::history(&ctx.pool, &uid).await.unwrap_or_default();
|
||||||
|
let weak = analytics::weakest(&ctx.pool, &uid).await.unwrap_or_default();
|
||||||
|
let marks: i32 = tests.iter().map(|t| t.score).sum();
|
||||||
|
let gained: i32 = tests.iter().map(|t| t.right).sum();
|
||||||
|
let lost: i32 = tests.iter().map(|t| t.wrong).sum();
|
||||||
|
let notes = coach_notes(
|
||||||
|
&analytics::Analysis {
|
||||||
|
score: tests.first().map(|t| t.score).unwrap_or(0),
|
||||||
|
max_score: tests.first().map(|t| t.max_score).unwrap_or(0),
|
||||||
|
used_secs: tests.first().map(|t| t.used_secs).unwrap_or(0),
|
||||||
|
right: tests.first().map(|t| t.right).unwrap_or(0),
|
||||||
|
wrong: tests.first().map(|t| t.wrong).unwrap_or(0),
|
||||||
|
skipped: 0,
|
||||||
|
by_subject: vec![],
|
||||||
|
by_difficulty: vec![],
|
||||||
|
by_topic: vec![],
|
||||||
|
mistakes: vec![],
|
||||||
|
},
|
||||||
|
&tests,
|
||||||
|
);
|
||||||
|
hx(views::dashboard(&ctx, &name, &tests, &weak, ¬es, (tests.len() as i32, marks, gained, lost)), StatusCode::OK).into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn shared_paper(State(ctx): State<CtxArc>, Path(token): Path<String>) -> axum::response::Response {
|
||||||
|
let row = sqlx::query("SELECT id, title, duration_min FROM papers WHERE share_token=?").bind(&token).fetch_optional(&ctx.pool).await;
|
||||||
|
let (id, title, duration) = match row {
|
||||||
|
Ok(Some(r)) => {
|
||||||
|
use sqlx::Row;
|
||||||
|
(r.get::<String, _>("id"), r.get::<String, _>("title"), r.get::<i64, _>("duration_min"))
|
||||||
|
}
|
||||||
|
_ => return hx(views::error(&ctx, 404, ""), StatusCode::NOT_FOUND).into_response(),
|
||||||
|
};
|
||||||
|
let questions = match exam::paper_questions(&ctx.pool, &id).await {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(_) => return hx(views::error(&ctx, 500, ""), StatusCode::INTERNAL_SERVER_ERROR).into_response(),
|
||||||
|
};
|
||||||
|
hx(views::shared(&ctx, &title, duration, &questions), StatusCode::OK).into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
const TERMS: &str = include_str!("../content/terms.html");
|
||||||
|
const PRIVACY: &str = include_str!("../content/privacy.html");
|
||||||
563
src/views.rs
Normal file
563
src/views.rs
Normal file
|
|
@ -0,0 +1,563 @@
|
||||||
|
use crate::analytics::{accuracy, fmt_dur, fmt_pct, Analysis, Bucket, TestRow};
|
||||||
|
use crate::auth::Ctx;
|
||||||
|
use crate::exam::{BuildForm, Question};
|
||||||
|
|
||||||
|
pub fn esc(s: &str) -> String {
|
||||||
|
let mut o = String::with_capacity(s.len() + 16);
|
||||||
|
for c in s.chars() {
|
||||||
|
match c {
|
||||||
|
'&' => o.push_str("&"),
|
||||||
|
'<' => o.push_str("<"),
|
||||||
|
'>' => o.push_str(">"),
|
||||||
|
'"' => o.push_str("""),
|
||||||
|
'\'' => o.push_str("'"),
|
||||||
|
_ => o.push(c),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
o
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct ChapterBlock {
|
||||||
|
pub id: i64,
|
||||||
|
pub name: String,
|
||||||
|
pub subtopics: Vec<String>,
|
||||||
|
pub count: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct SubjectBlock {
|
||||||
|
pub subject: String,
|
||||||
|
pub chapters: Vec<ChapterBlock>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn head(ctx: &Ctx, title: &str) -> String {
|
||||||
|
format!(
|
||||||
|
r#"<!doctype html><html lang="en"><head><meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<title>{t} · PYQ</title><link rel="stylesheet" href="{b}/static/style.css">
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
|
||||||
|
</head><body>"#,
|
||||||
|
t = esc(title),
|
||||||
|
b = ctx.base
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn nav(ctx: &Ctx, user: Option<&str>) -> String {
|
||||||
|
let brand = format!(
|
||||||
|
r#"<header class="topbar"><a class="brand" href="{b}/">PYQ<span>jee main practice</span></a><nav class="nav">"#,
|
||||||
|
b = ctx.base
|
||||||
|
);
|
||||||
|
let links = match user {
|
||||||
|
Some(_) => format!(
|
||||||
|
r#"<a class="hidem" href="{b}/build">build</a><a class="hidem" href="{b}/dashboard">dashboard</a><a class="hidem" href="{b}/history">history</a><a class="btn" href="{b}/logout">sign out</a>"#,
|
||||||
|
b = ctx.base
|
||||||
|
),
|
||||||
|
None => format!(
|
||||||
|
r#"<a class="hidem" href="{b}/terms">terms</a><a class="hidem" href="{b}/privacy">privacy</a><a class="btn primary" href="{b}/build">start a paper</a>"#,
|
||||||
|
b = ctx.base
|
||||||
|
),
|
||||||
|
};
|
||||||
|
format!("{brand}{links}</nav></header>")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn foot(ctx: &Ctx) -> String {
|
||||||
|
format!(
|
||||||
|
r#"<footer><span>PYQ clone · running on sqlite · built as a working copy of zenithprepare.com/PYQ</span><span><a href="{b}/terms">terms</a> · <a href="{b}/privacy">privacy</a></span></footer></body></html>"#,
|
||||||
|
b = ctx.base
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn shell(ctx: &Ctx, user: Option<&str>, title: &str, body: String) -> String {
|
||||||
|
format!("{}{}{}{}", head(ctx, title), nav(ctx, user), body, foot(ctx))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn landing(ctx: &Ctx, q: i64, shifts: i64, subtopics: i64, subjects: i64, students: i64, signed: bool) -> String {
|
||||||
|
let cta = if signed {
|
||||||
|
format!(r#"<a class="btn primary" href="{b}/build">build a paper</a>"#, b = ctx.base)
|
||||||
|
} else {
|
||||||
|
format!(r#"<a class="btn primary" href="{b}/build">start a paper</a>"#, b = ctx.base)
|
||||||
|
};
|
||||||
|
let body = format!(
|
||||||
|
r#"<h1>JEE Main practice, built like the real exam screen.</h1>
|
||||||
|
<p class="meta">Pick the chapters you just revised. Take the paper on a timed computer-based screen with the +4 / −1 marking scheme. Get the analysis the moment you submit.</p>
|
||||||
|
<div class="kv" style="margin-top:22px">
|
||||||
|
<div class="card"><span class="dim">questions</span><b>{q}</b></div>
|
||||||
|
<div class="card"><span class="dim">shifts</span><b>{shifts}</b></div>
|
||||||
|
<div class="card"><span class="dim">subtopics</span><b>{subtopics}</b></div>
|
||||||
|
<div class="card"><span class="dim">students</span><b>{students}</b></div>
|
||||||
|
</div>
|
||||||
|
<div class="grid2" style="margin-top:18px">
|
||||||
|
<div class="card"><h3>Build</h3><p class="meta">Choose any mix of chapters across {subjects} subjects. Questions are drawn from real shift data and, with unseen-only on, you never repeat one by accident.</p></div>
|
||||||
|
<div class="card"><h3>Attempt</h3><p class="meta">Subject tabs, a colour-coded question palette, mark for review, numerical answers, a countdown timer, and +4 for a correct answer with −1 for a wrong one.</p></div>
|
||||||
|
<div class="card"><h3>Analysis</h3><p class="meta">Score, accuracy, time per question, and results broken down by subject, chapter and difficulty. Solutions sit under every question.</p></div>
|
||||||
|
<div class="card"><h3>Progress</h3><p class="meta">Every submitted paper is kept. The dashboard tracks percentage and accuracy across tests and names the subtopics costing you the most marks.</p></div>
|
||||||
|
</div>
|
||||||
|
<p style="margin-top:20px">{cta}</p>"#,
|
||||||
|
q = q,
|
||||||
|
shifts = shifts,
|
||||||
|
subtopics = subtopics,
|
||||||
|
subjects = subjects,
|
||||||
|
students = students,
|
||||||
|
cta = cta
|
||||||
|
);
|
||||||
|
shell(ctx, None, "Home", body)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn student(ctx: &Ctx, next: &str, err: Option<&str>) -> String {
|
||||||
|
let e = err.map(|m| format!(r#"<p class="bad-t">{m}</p>"#)).unwrap_or_default();
|
||||||
|
let body = format!(
|
||||||
|
r#"<h1>Sign in</h1>
|
||||||
|
<p class="meta">This clone runs a local sign-in so you can use the whole flow without a Google account. Enter any name and email; your papers stay in this database.</p>
|
||||||
|
<form method="post" action="{b}/student" style="max-width:420px;margin-top:18px">
|
||||||
|
<input type="hidden" name="next" value="{next}">
|
||||||
|
<label for="name">name</label><input id="name" name="name" type="text" autocomplete="name" required>
|
||||||
|
<label for="email">email</label><input id="email" name="email" type="email" autocomplete="email" required>
|
||||||
|
{e}
|
||||||
|
<div style="margin-top:14px"><button class="btn primary" type="submit">sign in</button></div>
|
||||||
|
</form>"#,
|
||||||
|
b = ctx.base,
|
||||||
|
next = esc(next),
|
||||||
|
e = e
|
||||||
|
);
|
||||||
|
shell(ctx, None, "Sign in", body)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn legal(ctx: &Ctx, title: &str, body_md: &str) -> String {
|
||||||
|
let body = format!(r#"<h1>{t}</h1><div style="max-width:70ch">{b}</div>"#, t = esc(title), b = body_md);
|
||||||
|
shell(ctx, None, title, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn error(ctx: &Ctx, code: u16, detail: &str) -> String {
|
||||||
|
let (t, d) = match code {
|
||||||
|
404 => ("Nothing at this address".to_string(), "The link may be wrong, or the paper was never created.".to_string()),
|
||||||
|
_ => ("Something broke on our side".to_string(), detail.to_string()),
|
||||||
|
};
|
||||||
|
let body = format!(
|
||||||
|
r#"<h1>{t}</h1><p class="meta">{d}</p><p style="margin-top:16px"><a class="btn" href="{b}/">back to home</a></p>"#,
|
||||||
|
t = esc(&t),
|
||||||
|
d = esc(&d),
|
||||||
|
b = ctx.base
|
||||||
|
);
|
||||||
|
shell(ctx, None, &t, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn build(ctx: &Ctx, user: &str, blocks: Vec<SubjectBlock>, _form: &BuildForm, available: i64, err: Option<&str>) -> String {
|
||||||
|
let mut picker = String::new();
|
||||||
|
for sb in &blocks {
|
||||||
|
picker.push_str(&format!("<h3>{}</h3>", esc(&sb.subject)));
|
||||||
|
for ch in &sb.chapters {
|
||||||
|
let subs = ch.subtopics.iter().map(|s| esc(s)).collect::<Vec<_>>().join(", ");
|
||||||
|
picker.push_str(&format!(
|
||||||
|
r#"<label class="chk"><input type="checkbox" class="chap" value="{cid}"><span><b>{cname}</b><br><span class="dim">{subs} · {count} questions</span></span></label>"#,
|
||||||
|
cid = ch.id,
|
||||||
|
cname = esc(&ch.name),
|
||||||
|
subs = subs,
|
||||||
|
count = ch.count
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let e = err.map(|m| format!(r#"<p class="bad-t">{m}</p>"#)).unwrap_or_default();
|
||||||
|
let body = format!(
|
||||||
|
r#"<h1>Build a paper</h1>
|
||||||
|
<p class="meta">{available} questions match this bank. Pick the chapters you are revising; the paper is drawn from them and marked on the +4 / −1 scheme.</p>
|
||||||
|
<form method="post" action="{b}/build" style="margin-top:18px">
|
||||||
|
<input type="hidden" name="chapters" id="chapters" value="">
|
||||||
|
<div class="grid2"><div>{picker}</div>
|
||||||
|
<div>
|
||||||
|
<div style="position:sticky;top:64px">
|
||||||
|
<h2>paper settings</h2>
|
||||||
|
<label for="count">questions</label>
|
||||||
|
<select id="count" name="count">
|
||||||
|
<option value="9">9 · short drill</option><option value="12" selected>12</option>
|
||||||
|
<option value="18">18</option><option value="25">25</option><option value="36">36 · full paper</option>
|
||||||
|
</select>
|
||||||
|
<label for="duration">duration in minutes</label>
|
||||||
|
<input id="duration" name="duration" type="number" value="24" min="5" max="180">
|
||||||
|
<label class="chk" style="margin-top:14px"><input type="checkbox" name="unseen_only" checked><span>unseen questions only<br><span class="dim">skips anything you have already answered once</span></span></label>
|
||||||
|
<p class="dim" id="pick-note" style="margin-top:10px">no chapters picked yet</p>
|
||||||
|
{e}
|
||||||
|
<div style="margin-top:14px"><button class="btn primary" type="submit">create paper</button></div>
|
||||||
|
</div>
|
||||||
|
</div></div>
|
||||||
|
</form>
|
||||||
|
<script>// Repeated checkbox keys do not survive a urlencoded body, so the ids are joined into one field.
|
||||||
|
(() => {{
|
||||||
|
const boxes = [...document.querySelectorAll('.chap')];
|
||||||
|
const field = document.getElementById('chapters');
|
||||||
|
const note = document.getElementById('pick-note');
|
||||||
|
const sync = () => {{
|
||||||
|
field.value = boxes.filter(b => b.checked).map(b => b.value).join(',');
|
||||||
|
note.textContent = field.value ? field.value.split(',').length + ' chapters picked' : 'no chapters picked yet';
|
||||||
|
}};
|
||||||
|
boxes.forEach(b => b.addEventListener('change', sync));
|
||||||
|
sync();
|
||||||
|
}})();</script>"#,
|
||||||
|
available = available,
|
||||||
|
b = ctx.base,
|
||||||
|
picker = picker,
|
||||||
|
e = e
|
||||||
|
);
|
||||||
|
shell(ctx, Some(user), "Build", body)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn palette(questions: &[Question], answers: &[(i64, String, bool)]) -> String {
|
||||||
|
questions
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, q)| {
|
||||||
|
let a = answers.iter().find(|(id, _, _)| *id == q.id);
|
||||||
|
let sel = a.map(|(_, s, _)| !s.trim().is_empty()).unwrap_or(false);
|
||||||
|
let rev = a.map(|(_, _, r)| *r).unwrap_or(false);
|
||||||
|
let cls = match (sel, rev) {
|
||||||
|
(true, true) => "pal rev",
|
||||||
|
(true, false) => "pal ans",
|
||||||
|
_ => "pal",
|
||||||
|
};
|
||||||
|
format!(r#"<button type="button" class="{cls}" data-jump="{i}">{n}</button>"#, cls = cls, n = i + 1)
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn attempt(ctx: &Ctx, user: &str, attempt_id: &str, questions: &[Question], answers: &[(i64, String, bool)], duration_min: i64, title: &str) -> String {
|
||||||
|
let body_questions: Vec<String> = questions
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, q)| {
|
||||||
|
let cur = answers.iter().find(|(id, _, _)| *id == q.id);
|
||||||
|
let sel = cur.map(|(_, s, _)| s.clone()).unwrap_or_default();
|
||||||
|
let rev = cur.map(|(_, _, r)| *r).unwrap_or(false);
|
||||||
|
let opts: Vec<String> = if q.qtype == "NUM" {
|
||||||
|
vec![format!(
|
||||||
|
r#"<input type="text" inputmode="decimal" name="num-{id}" value="{v}" placeholder="numeric answer">"#,
|
||||||
|
id = q.id,
|
||||||
|
v = esc(&sel)
|
||||||
|
)]
|
||||||
|
} else {
|
||||||
|
["A", "B", "C", "D"]
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(k, letter)| {
|
||||||
|
let text = q.opts[k].clone();
|
||||||
|
let checked = if sel.trim().eq_ignore_ascii_case(letter) { " checked" } else { "" };
|
||||||
|
format!(
|
||||||
|
r#"<label class="opt{cls}"><input type="radio" name="q-{id}" value="{letter}"{checked}><span><b>{letter}</b> {t}</span></label>"#,
|
||||||
|
cls = if sel.trim().eq_ignore_ascii_case(letter) { " sel" } else { "" },
|
||||||
|
id = q.id,
|
||||||
|
letter = letter,
|
||||||
|
checked = checked,
|
||||||
|
t = esc(&text)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
};
|
||||||
|
format!(
|
||||||
|
r#"<section class="card qpane" id="qpane-{i}" data-i="{i}"{hidden}>
|
||||||
|
<div style="display:flex;justify-content:space-between;align-items:center;gap:10px;flex-wrap:wrap">
|
||||||
|
<span class="dim">question {n} · {subj} · {ch}</span>
|
||||||
|
<span class="dim">{qt} · +4 / −1{revflag}</span>
|
||||||
|
</div>
|
||||||
|
<p style="margin:10px 0 4px;color:#fff">{stem}</p>
|
||||||
|
<div class="dim" style="margin-bottom:8px">{sub2} · {year} {shift} · {difficulty}</div>
|
||||||
|
{opts}
|
||||||
|
<div style="display:flex;gap:8px;flex-wrap:wrap;margin-top:12px">
|
||||||
|
<button type="button" class="btn ok" data-act="save">save & next</button>
|
||||||
|
<button type="button" class="btn" data-act="review">mark for review</button>
|
||||||
|
<button type="button" class="btn" data-act="clear">clear</button>
|
||||||
|
</div>
|
||||||
|
</section>"#,
|
||||||
|
hidden = if i == 0 { "" } else { " hidden" },
|
||||||
|
n = i + 1,
|
||||||
|
subj = esc(&q.subject),
|
||||||
|
ch = esc(&q.chapter),
|
||||||
|
qt = esc(&q.qtype),
|
||||||
|
revflag = if rev { " · <span class=\"warn-t\">marked</span>" } else { "" },
|
||||||
|
stem = esc(&q.stem),
|
||||||
|
sub2 = esc(&q.subtopic),
|
||||||
|
year = q.year,
|
||||||
|
shift = esc(&q.shift),
|
||||||
|
difficulty = esc(&q.difficulty),
|
||||||
|
opts = opts.join("")
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let body = format!(
|
||||||
|
r#"<div style="display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap;margin:14px 0">
|
||||||
|
<div><h1 style="margin:0">{title}</h1><span class="dim">{n} questions · +4 / −1</span></div>
|
||||||
|
<div style="text-align:right"><div class="timer" id="timer" data-secs="{secs}">{dur}</div><div class="dim">remaining</div></div>
|
||||||
|
</div>
|
||||||
|
<form method="post" action="{b}/attempt/{a}/submit" id="exam-form" data-attempt="{a}">
|
||||||
|
<div class="cbt">
|
||||||
|
<div>{qs}</div>
|
||||||
|
<div><h2>palette</h2><div class="palette" id="pal">{pal}</div>
|
||||||
|
<p class="dim" style="margin-top:10px"><span class="ok-t">answered</span> · <span class="warn-t">review</span> · <span class="dim">blank</span></p>
|
||||||
|
<button class="btn primary" type="submit" style="width:100%;margin-top:12px">submit paper</button>
|
||||||
|
</div>
|
||||||
|
</div></form>
|
||||||
|
<script src="{b}/static/exam.js"></script>"#,
|
||||||
|
title = esc(title),
|
||||||
|
n = questions.len(),
|
||||||
|
dur = fmt_dur(duration_min as i64 * 60),
|
||||||
|
secs = duration_min * 60,
|
||||||
|
b = ctx.base,
|
||||||
|
a = esc(attempt_id),
|
||||||
|
qs = body_questions.join(""),
|
||||||
|
pal = palette(questions, answers)
|
||||||
|
);
|
||||||
|
shell(ctx, Some(user), "Test", body)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct RowView<'a> {
|
||||||
|
pub q: &'a Question,
|
||||||
|
pub selected: &'a str,
|
||||||
|
pub marks: i32,
|
||||||
|
pub correct: bool,
|
||||||
|
pub time_secs: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn seg(b: &Bucket) -> String {
|
||||||
|
let t = b.total().max(1) as f64;
|
||||||
|
format!(
|
||||||
|
r#"<div class="seg"><i style="width:{}%;background:#10b981"></i><i style="width:{}%;background:#ff5555"></i><i style="width:{}%;background:#333"></i></div>"#,
|
||||||
|
b.right as f64 / t * 100.0,
|
||||||
|
b.wrong as f64 / t * 100.0,
|
||||||
|
b.skipped as f64 / t * 100.0
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bucket_rows(items: &[Bucket]) -> String {
|
||||||
|
items
|
||||||
|
.iter()
|
||||||
|
.map(|b| {
|
||||||
|
format!(
|
||||||
|
r#"<tr><td>{l}</td><td>{r}</td><td>{w}</td><td>{s}</td><td style="width:38%">{seg}</td><td>{p}%</td><td class="{st}">{st}</td></tr>"#,
|
||||||
|
l = esc(&b.label),
|
||||||
|
r = b.right,
|
||||||
|
w = b.wrong,
|
||||||
|
s = b.skipped,
|
||||||
|
seg = seg(b),
|
||||||
|
p = b.pct(),
|
||||||
|
st = b.status()
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn result(ctx: &Ctx, user: &str, _attempt_id: &str, a: &Analysis, rows: &[RowView]) -> String {
|
||||||
|
let detail: String = rows
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, r)| {
|
||||||
|
let state = if r.correct { "correct" } else if r.selected.trim().is_empty() { "blank" } else { "wrong" };
|
||||||
|
let cls = match state { "correct" => "ok-t", "wrong" => "bad-t", _ => "dim" };
|
||||||
|
let opts: String = if r.q.qtype == "NUM" {
|
||||||
|
format!(r#"<p class="dim">your answer: {sel} · correct: {ans}</p>"#, sel = esc(if r.selected.is_empty() { "not attempted" } else { r.selected }), ans = esc(&r.q.answer))
|
||||||
|
} else {
|
||||||
|
["A", "B", "C", "D"]
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(k, l)| {
|
||||||
|
let is_ans = r.q.answer.trim().eq_ignore_ascii_case(l);
|
||||||
|
let is_sel = r.selected.trim().eq_ignore_ascii_case(l);
|
||||||
|
let style = if is_ans { "ok-t" } else if is_sel { "bad-t" } else { "dim" };
|
||||||
|
format!(r#"<span style="margin-right:10px" class="{style}">{l}: {t}{mark}</span>"#, style = style, l = l, t = esc(&r.q.opts[k]), mark = if is_ans { " (correct)" } else if is_sel { " (you)" } else { "" })
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
};
|
||||||
|
format!(
|
||||||
|
r#"<details class="card" style="margin-bottom:8px"><summary style="cursor:pointer">q{n} · {subj} · <span class="{cls}">{state}</span> · {marks:+} marks</summary>
|
||||||
|
<p style="margin:12px 0 6px;color:#fff">{stem}</p>
|
||||||
|
<p style="margin-bottom:8px">{opts}</p>
|
||||||
|
<p class="dim" style="margin-bottom:8px">{sub2} · {ch} · {year} {shift} · {difficulty} · {qtime}</p>
|
||||||
|
<p style="color:#38bdf8">{sol}</p></details>"#,
|
||||||
|
n = i + 1,
|
||||||
|
subj = esc(&r.q.subject),
|
||||||
|
cls = cls,
|
||||||
|
state = state,
|
||||||
|
marks = r.marks,
|
||||||
|
stem = esc(&r.q.stem),
|
||||||
|
opts = opts,
|
||||||
|
sub2 = esc(&r.q.subtopic),
|
||||||
|
ch = esc(&r.q.chapter),
|
||||||
|
year = r.q.year,
|
||||||
|
shift = esc(&r.q.shift),
|
||||||
|
difficulty = esc(&r.q.difficulty),
|
||||||
|
qtime = if r.time_secs > 0 { format!("{}s spent", r.time_secs) } else { "no time recorded".to_string() },
|
||||||
|
sol = esc(&r.q.solution)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let topic_buckets: Vec<Bucket> = a.by_topic.iter().map(|(b, _)| b.to_owned()).collect();
|
||||||
|
let body = format!(
|
||||||
|
r#"<h1>Result</h1>
|
||||||
|
<div class="kv">
|
||||||
|
<div class="card"><span class="dim">score</span><b>{score} <span class="dim">/ {max}</span></b></div>
|
||||||
|
<div class="card"><span class="dim">percentage</span><b>{pct}%</b></div>
|
||||||
|
<div class="card"><span class="dim">accuracy</span><b class="ok-t">{acc}%</b></div>
|
||||||
|
<div class="card"><span class="dim">time used</span><b>{dur}</b></div>
|
||||||
|
</div>
|
||||||
|
<div class="grid2">
|
||||||
|
<div class="card"><h3>by subject</h3><table><thead><tr><th>subject</th><th>right</th><th>wrong</th><th>blank</th><th></th><th>%</th><th></th></tr></thead><tbody>{subj}</tbody></table></div>
|
||||||
|
<div class="card"><h3>by difficulty</h3><table><thead><tr><th>difficulty</th><th>right</th><th>wrong</th><th>blank</th><th></th><th>%</th><th></th></tr></thead><tbody>{diff}</tbody></table></div>
|
||||||
|
</div>
|
||||||
|
<h2>chapters that cost you marks</h2>
|
||||||
|
<div class="card"><table><thead><tr><th>subtopic</th><th>right</th><th>wrong</th><th>blank</th><th></th><th>%</th><th></th></tr></thead><tbody>{topic}</tbody></table></div>
|
||||||
|
<h2>every question</h2>
|
||||||
|
{detail}
|
||||||
|
<p style="margin-top:18px;display:flex;gap:8px;flex-wrap:wrap">
|
||||||
|
<a class="btn" href="{b}/history">all papers</a>
|
||||||
|
<a class="btn" href="{b}/dashboard">dashboard</a>
|
||||||
|
<a class="btn primary" href="{b}/build">new paper</a>
|
||||||
|
</p>"#,
|
||||||
|
score = a.score,
|
||||||
|
max = a.max_score,
|
||||||
|
pct = fmt_pct(a.score, a.max_score),
|
||||||
|
acc = accuracy(a.right, a.wrong),
|
||||||
|
dur = fmt_dur(a.used_secs),
|
||||||
|
subj = bucket_rows(&a.by_subject),
|
||||||
|
diff = bucket_rows(&a.by_difficulty),
|
||||||
|
topic = bucket_rows(&topic_buckets),
|
||||||
|
detail = detail,
|
||||||
|
b = ctx.base
|
||||||
|
);
|
||||||
|
shell(ctx, Some(user), "Result", body)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn history(ctx: &Ctx, user: &str, tests: &[TestRow]) -> String {
|
||||||
|
let rows: String = if tests.is_empty() {
|
||||||
|
String::new()
|
||||||
|
} else {
|
||||||
|
tests
|
||||||
|
.iter()
|
||||||
|
.map(|t| {
|
||||||
|
format!(
|
||||||
|
r#"<tr><td>{d}</td><td>{title}</td><td>{score} / {max}</td><td>{p}%</td><td>{acc}%</td><td>{dur}</td><td><a href="{b}/result/{id}">open</a></td></tr>"#,
|
||||||
|
d = t.started_at.format("%d %b %Y"),
|
||||||
|
title = esc(&t.title),
|
||||||
|
score = t.score,
|
||||||
|
max = t.max_score,
|
||||||
|
p = fmt_pct(t.score, t.max_score),
|
||||||
|
acc = accuracy(t.right, t.wrong),
|
||||||
|
dur = fmt_dur(t.used_secs),
|
||||||
|
b = ctx.base,
|
||||||
|
id = esc(&t.attempt_id)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
};
|
||||||
|
let body = if tests.is_empty() {
|
||||||
|
format!(
|
||||||
|
r#"<h1>History</h1><div class="card" style="margin-top:16px;max-width:60ch"><h3>No papers yet</h3><p class="meta">Nothing is here because no paper has been submitted. Build one and it appears here with its score and accuracy.</p><p style="margin-top:12px"><a class="btn primary" href="{b}/build">build your first paper</a></p></div>"#,
|
||||||
|
b = ctx.base
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
format!(
|
||||||
|
r#"<h1>History</h1><div class="card" style="margin-top:16px;overflow-x:auto"><table><thead><tr><th>date</th><th>paper</th><th>score</th><th>%</th><th>accuracy</th><th>time</th><th></th></tr></thead><tbody>{rows}</tbody></table></div>"#,
|
||||||
|
rows = rows
|
||||||
|
)
|
||||||
|
};
|
||||||
|
shell(ctx, Some(user), "History", body)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn dashboard(ctx: &Ctx, user: &str, tests: &[TestRow], weak: &[(Bucket, String, String)], notes: &[String], totals: (i32, i32, i32, i32)) -> String {
|
||||||
|
let (taken, marks, gained, lost) = totals;
|
||||||
|
let trend: String = if tests.is_empty() {
|
||||||
|
String::new()
|
||||||
|
} else {
|
||||||
|
let best = tests.iter().map(|t| t.score * 100 / t.max_score.max(1)).max().unwrap_or(0);
|
||||||
|
let mut s = String::from(r#"<h2>percentage across papers</h2><div class="card"><table><thead><tr><th>date</th><th>paper</th><th>%</th><th>accuracy</th></tr></thead><tbody>"#);
|
||||||
|
for t in tests {
|
||||||
|
s.push_str(&format!(
|
||||||
|
r#"<tr><td>{}</td><td>{}</td><td>{}%</td><td>{}%</td></tr>"#,
|
||||||
|
t.started_at.format("%d %b"),
|
||||||
|
esc(&t.title),
|
||||||
|
fmt_pct(t.score, t.max_score),
|
||||||
|
accuracy(t.right, t.wrong)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
s.push_str("</tbody></table></div>");
|
||||||
|
s = s.replace("{best}", &best.to_string());
|
||||||
|
s
|
||||||
|
};
|
||||||
|
let weak_rows: String = weak
|
||||||
|
.iter()
|
||||||
|
.map(|(b, chapter, subtopic)| {
|
||||||
|
format!(
|
||||||
|
r#"<tr><td>{sub} <span class="dim">· {ch}</span></td><td>{t}</td><td>{w}</td><td>{s}</td><td>{seg}</td><td class="{st}">{st}</td></tr>"#,
|
||||||
|
sub = esc(subtopic),
|
||||||
|
ch = esc(chapter),
|
||||||
|
t = b.total(),
|
||||||
|
w = b.wrong,
|
||||||
|
s = b.skipped,
|
||||||
|
seg = seg(b),
|
||||||
|
st = b.status()
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let notes_html: String = notes.iter().map(|n| format!("<li>{}</li>", esc(n))).collect();
|
||||||
|
let body = if tests.is_empty() {
|
||||||
|
format!(
|
||||||
|
r#"<h1>Dashboard</h1><div class="card" style="margin-top:16px;max-width:60ch"><h3>No test data yet</h3><p class="meta">The trend, the weak-subtopic table and the coach notes all come from papers you submit, so there is nothing to show until the first one is done.</p><p style="margin-top:12px"><a class="btn primary" href="{b}/build">build a paper</a></p></div>"#,
|
||||||
|
b = ctx.base
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
format!(
|
||||||
|
r#"<h1>Dashboard</h1>
|
||||||
|
<div class="kv">
|
||||||
|
<div class="card"><span class="dim">papers submitted</span><b>{taken}</b></div>
|
||||||
|
<div class="card"><span class="dim">marks scored</span><b>{marks}</b></div>
|
||||||
|
<div class="card"><span class="dim">correct</span><b class="ok-t">{gained}</b></div>
|
||||||
|
<div class="card"><span class="dim">wrong</span><b class="bad-t">{lost}</b></div>
|
||||||
|
</div>
|
||||||
|
{trend}
|
||||||
|
<h2>weakest subtopics across all papers</h2>
|
||||||
|
<div class="card"><table><thead><tr><th>subtopic</th><th>seen</th><th>wrong</th><th>blank</th><th></th><th></th></tr></thead><tbody>{weak}</tbody></table></div>
|
||||||
|
<h2>coach notes</h2>
|
||||||
|
<div class="card"><ul style="padding-left:18px;display:grid;gap:6px">{notes}</ul><p class="dim" style="margin-top:10px">Written from your own submitted papers. No data leaves this machine.</p></div>
|
||||||
|
<p style="margin-top:18px"><a class="btn" href="{b}/history">all papers</a></p>"#,
|
||||||
|
taken = taken,
|
||||||
|
marks = marks,
|
||||||
|
gained = gained,
|
||||||
|
lost = lost,
|
||||||
|
trend = trend,
|
||||||
|
weak = weak_rows,
|
||||||
|
notes = notes_html,
|
||||||
|
b = ctx.base
|
||||||
|
)
|
||||||
|
};
|
||||||
|
shell(ctx, Some(user), "Dashboard", body)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn shared(ctx: &Ctx, title: &str, duration: i64, questions: &[Question]) -> String {
|
||||||
|
let qs: String = questions
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, q)| {
|
||||||
|
let opts = if q.qtype == "NUM" {
|
||||||
|
String::new()
|
||||||
|
} else {
|
||||||
|
["A", "B", "C", "D"]
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(k, l)| format!("<p><b>{l}</b> {}</p>", esc(&q.opts[k])))
|
||||||
|
.collect()
|
||||||
|
};
|
||||||
|
format!(
|
||||||
|
r#"<details class="card" style="margin-bottom:8px"><summary style="cursor:pointer">q{n} · {subj} · {sub2}</summary><p style="margin:12px 0 8px;color:#fff">{stem}</p>{opts}</details>"#,
|
||||||
|
n = i + 1,
|
||||||
|
subj = esc(&q.subject),
|
||||||
|
sub2 = esc(&q.subtopic),
|
||||||
|
stem = esc(&q.stem),
|
||||||
|
opts = opts
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let body = format!(
|
||||||
|
r#"<h1>{title}</h1><p class="meta">{count} questions · {dur} minutes · no answers or scores on this page.</p><div style="margin-top:16px">{qs}</div>"#,
|
||||||
|
title = esc(title),
|
||||||
|
count = questions.len(),
|
||||||
|
dur = duration,
|
||||||
|
qs = qs
|
||||||
|
);
|
||||||
|
shell(ctx, None, title, body)
|
||||||
|
}
|
||||||
133
static/exam.js
Normal file
133
static/exam.js
Normal file
|
|
@ -0,0 +1,133 @@
|
||||||
|
// Exam screen: pane navigation, palette sync, per-question timing and autosave.
|
||||||
|
(() => {
|
||||||
|
const panes = [...document.querySelectorAll('.qpane')];
|
||||||
|
const palBtns = [...document.querySelectorAll('#pal .pal')];
|
||||||
|
const timerEl = document.getElementById('timer');
|
||||||
|
let cur = 0;
|
||||||
|
|
||||||
|
const entered = new Map();
|
||||||
|
for (const p of panes) {
|
||||||
|
const box = p.querySelector('input');
|
||||||
|
if (box) entered.set(p.dataset.i, Number(box.dataset.t || 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
function show(i) {
|
||||||
|
cur = Math.max(0, Math.min(panes.length - 1, i));
|
||||||
|
panes.forEach((p, k) => { p.hidden = k !== cur; });
|
||||||
|
palBtns.forEach((b, k) => b.classList.toggle('cur', k === cur));
|
||||||
|
const focusable = panes[cur].querySelector('input:not([type=hidden])');
|
||||||
|
if (focusable && matchMedia('(pointer: fine)').matches) focusable.focus({ preventScroll: true });
|
||||||
|
history.replaceState(null, '', '#q' + (cur + 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
function questionId(pane) {
|
||||||
|
const radios = [...pane.querySelectorAll('input[type=radio]')];
|
||||||
|
if (radios.length) return Number(radios[0].name.replace('q-', ''));
|
||||||
|
return Number(pane.querySelector('input[type=text]').name.replace('num-', ''));
|
||||||
|
}
|
||||||
|
|
||||||
|
function collect(pane) {
|
||||||
|
const i = pane.dataset.i;
|
||||||
|
const radios = [...pane.querySelectorAll('input[type=radio]')];
|
||||||
|
const picked = radios.find(r => r.checked);
|
||||||
|
const num = pane.querySelector('input[type=text]');
|
||||||
|
return {
|
||||||
|
question_id: questionId(pane),
|
||||||
|
selected: picked ? picked.value : (num ? num.value.trim() : ''),
|
||||||
|
marked_review: pane.dataset.review === '1',
|
||||||
|
time_secs: Number(entered.get(i) || 0),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncPalette(pane) {
|
||||||
|
const btn = palBtns[Number(pane.dataset.i)];
|
||||||
|
if (!btn) return;
|
||||||
|
const answered = collect(pane).selected !== '';
|
||||||
|
const review = pane.dataset.review === '1';
|
||||||
|
btn.classList.toggle('ans', answered && !review);
|
||||||
|
btn.classList.toggle('rev', answered && review);
|
||||||
|
btn.classList.toggle('skip', !answered);
|
||||||
|
btn.classList.remove('cur');
|
||||||
|
}
|
||||||
|
|
||||||
|
let saving = false;
|
||||||
|
async function save(all) {
|
||||||
|
if (saving) return;
|
||||||
|
saving = true;
|
||||||
|
const panesToSave = all ? panes : [panes[cur]];
|
||||||
|
const attemptId = document.getElementById('exam-form').dataset.attempt;
|
||||||
|
try {
|
||||||
|
await fetch(`/attempt/${attemptId}/answer`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ answers: panesToSave.map(collect) }),
|
||||||
|
});
|
||||||
|
} catch (_) {
|
||||||
|
// Server unreachable: the selection stays on screen and travels with the form submit.
|
||||||
|
}
|
||||||
|
saving = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const pane of panes) {
|
||||||
|
pane.addEventListener('change', () => {
|
||||||
|
pane.querySelectorAll('.opt').forEach(o => {
|
||||||
|
const input = o.querySelector('input');
|
||||||
|
o.classList.toggle('sel', !!input && input.checked);
|
||||||
|
});
|
||||||
|
syncPalette(pane);
|
||||||
|
save(false);
|
||||||
|
});
|
||||||
|
pane.addEventListener('click', (e) => {
|
||||||
|
const btn = e.target.closest('button[data-act]');
|
||||||
|
if (!btn) return;
|
||||||
|
const act = btn.dataset.act;
|
||||||
|
if (act === 'save') { save(false).then(() => show(cur + 1)); }
|
||||||
|
if (act === 'review') {
|
||||||
|
pane.dataset.review = pane.dataset.review === '1' ? '0' : '1';
|
||||||
|
btn.classList.toggle('danger', pane.dataset.review === '1');
|
||||||
|
syncPalette(pane);
|
||||||
|
save(false);
|
||||||
|
}
|
||||||
|
if (act === 'clear') {
|
||||||
|
pane.querySelectorAll('input[type=radio]').forEach(r => { r.checked = false; });
|
||||||
|
const num = pane.querySelector('input[type=text]');
|
||||||
|
if (num) num.value = '';
|
||||||
|
pane.querySelectorAll('.opt').forEach(o => o.classList.remove('sel'));
|
||||||
|
syncPalette(pane);
|
||||||
|
save(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
palBtns.forEach((b, i) => b.addEventListener('click', () => show(i)));
|
||||||
|
|
||||||
|
document.addEventListener('keydown', e => {
|
||||||
|
if (/^(INPUT|TEXTAREA|SELECT)$/.test(e.target.tagName)) return;
|
||||||
|
if (e.key === 'ArrowRight') show(cur + 1);
|
||||||
|
if (e.key === 'ArrowLeft') show(cur - 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
let left = Number(timerEl.dataset.secs || 0);
|
||||||
|
if (left > 0) {
|
||||||
|
setInterval(() => {
|
||||||
|
left -= 1;
|
||||||
|
if (left <= 0) {
|
||||||
|
clearInterval(this);
|
||||||
|
document.getElementById('exam-form').requestSubmit();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const h = String(Math.floor(left / 3600)).padStart(2, '0');
|
||||||
|
const m = String(Math.floor(left / 60) % 60).padStart(2, '0');
|
||||||
|
const s = String(left % 60).padStart(2, '0');
|
||||||
|
timerEl.textContent = `${h}:${m}:${s}`;
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('exam-form').addEventListener('submit', e => {
|
||||||
|
if (!confirm('Submit this paper? You cannot change answers afterwards.')) e.preventDefault();
|
||||||
|
});
|
||||||
|
|
||||||
|
const hash = Number((location.hash.match(/q(\d+)/) || [])[1]);
|
||||||
|
show(hash ? hash - 1 : 0);
|
||||||
|
if (cur === 0 && !hash) panes.forEach(syncPalette);
|
||||||
|
})();
|
||||||
64
static/style.css
Normal file
64
static/style.css
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
*{box-sizing:border-box;margin:0;padding:0;border-radius:0!important}
|
||||||
|
:root{--bg:#0b0b0b;--panel:#111111;--card:#141414;--input:#0e0e0e;--border:#2e2e2e;--border2:#3d3d3d;--edge:#626262;--txt:#e5e5e5;--body:#cccccc;--muted:#9a9a9a;--dim:#7d7d7d;--ok:#10b981;--cyan:#38bdf8;--amber:#f59e0b;--bad:#ff5555;--mono:'JetBrains Mono',ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}
|
||||||
|
html{background:var(--bg)}
|
||||||
|
body{background:var(--bg);color:var(--body);font-family:var(--mono);font-size:14px;line-height:1.55;max-width:1180px;margin:0 auto;padding:0 16px 64px}
|
||||||
|
a{color:var(--cyan);text-decoration:none}
|
||||||
|
a:hover{text-decoration:underline}
|
||||||
|
h1{font-size:2.1rem;font-weight:600;letter-spacing:-.04em;color:#fff;line-height:1.15;margin:18px 0 10px}
|
||||||
|
h2{font-size:.76rem;font-weight:500;text-transform:uppercase;letter-spacing:.12em;color:var(--muted);margin:28px 0 12px;border-bottom:1px solid var(--border);padding-bottom:8px}
|
||||||
|
h3{color:#fff;font-size:1rem;margin:0 0 6px}
|
||||||
|
.topbar{display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--border);padding:12px 0;position:sticky;top:0;background:rgba(11,11,11,.92);z-index:5}
|
||||||
|
.brand{color:#fff;font-weight:600;font-size:1rem;letter-spacing:-.02em}
|
||||||
|
.brand span{color:var(--dim);font-weight:400;font-size:.78rem;margin-left:10px}
|
||||||
|
.nav{display:flex;gap:14px;align-items:center;font-size:.8rem}
|
||||||
|
.btn{display:inline-flex;align-items:center;justify-content:center;padding:8px 18px;background:#161616;color:#ddd;border:1px solid var(--edge);font-size:.8rem;font-family:inherit;cursor:pointer;letter-spacing:.02em;transition:all .15s ease;text-decoration:none}
|
||||||
|
.btn:hover:not(:disabled){background:#242424;border-color:#555;color:#fff;text-decoration:none}
|
||||||
|
.btn:active:not(:disabled){background:#fff;color:#000;border-color:#fff}
|
||||||
|
.btn:disabled{opacity:.3;cursor:not-allowed}
|
||||||
|
.btn.primary{background:var(--cyan);border-color:var(--cyan);color:#001018;font-weight:600}
|
||||||
|
.btn.primary:hover{background:#7cd4fc;color:#000}
|
||||||
|
.btn.ok{border-color:var(--ok);color:var(--ok)}
|
||||||
|
.btn.danger{border-color:var(--bad);color:var(--bad)}
|
||||||
|
.grid2{display:grid;grid-template-columns:1fr 1fr;gap:14px}
|
||||||
|
.grid3{display:grid;grid-template-columns:repeat(3,1fr);gap:14px}
|
||||||
|
.card{background:var(--card);border:1px solid var(--border);padding:16px}
|
||||||
|
.card:hover{border-color:var(--border2)}
|
||||||
|
.meta{color:var(--muted);font-size:.78rem}
|
||||||
|
.dim{color:var(--dim);font-size:.78rem}
|
||||||
|
table{width:100%;border-collapse:collapse;font-size:.82rem}
|
||||||
|
th{color:var(--dim);text-transform:uppercase;letter-spacing:.1em;font-size:.68rem;text-align:left;padding:8px;border-bottom:1px solid var(--border)}
|
||||||
|
td{padding:8px;border-bottom:1px solid #1c1c1c;font-variant-numeric:tabular-nums}
|
||||||
|
input[type=text],input[type=email],input[type=number],select,textarea{background:var(--input);color:#e5e5e5;border:1px solid var(--edge);padding:9px 12px;font-family:inherit;font-size:.82rem;outline:none;width:100%}
|
||||||
|
input:focus,textarea:focus,select:focus{border-color:var(--cyan)}
|
||||||
|
input::placeholder{color:var(--dim)}
|
||||||
|
label{display:block;font-size:.75rem;color:var(--muted);margin:10px 0 4px;text-transform:uppercase;letter-spacing:.08em}
|
||||||
|
.chk{display:flex;gap:10px;align-items:flex-start;border:1px solid var(--edge);padding:10px 12px;margin:6px 0;background:var(--panel);cursor:pointer;font-size:.85rem}
|
||||||
|
.chk:hover{border-color:var(--border2)}
|
||||||
|
.chk input{width:auto;margin-top:3px;accent-color:var(--cyan)}
|
||||||
|
.palette{display:grid;grid-template-columns:repeat(5,1fr);gap:6px}
|
||||||
|
.pal{aspect-ratio:1;display:grid;place-items:center;border:1px solid var(--edge);background:var(--panel);color:var(--muted);font-size:.75rem;cursor:pointer;min-height:44px}
|
||||||
|
.pal.cur{border-color:var(--cyan);color:#fff}
|
||||||
|
.pal.ans{background:#12301f;border-color:var(--ok);color:var(--ok)}
|
||||||
|
.pal.rev{background:#2a1f05;border-color:var(--amber);color:var(--amber)}
|
||||||
|
.pal.skip{background:var(--panel)}
|
||||||
|
.opt{display:flex;gap:10px;align-items:flex-start;border:1px solid var(--edge);padding:10px 12px;margin:8px 0;background:var(--panel);cursor:pointer}
|
||||||
|
.opt:hover{border-color:var(--border2)}
|
||||||
|
.opt.sel{border-color:var(--cyan);background:#0d1b24}
|
||||||
|
.status-dot{width:6px;height:6px;background:#444;display:inline-block;flex:none}
|
||||||
|
.ok-t{color:var(--ok)}.bad-t{color:var(--bad)}.warn-t{color:var(--amber)}.cyan-t{color:var(--cyan)}
|
||||||
|
.bar{height:2px;background:#1c1c1c;overflow:hidden;margin-top:8px}
|
||||||
|
.bar i{display:block;height:100%;background:var(--ok)}
|
||||||
|
.seg{display:flex;height:10px;background:#1c1c1c;overflow:hidden}
|
||||||
|
.seg i{display:block;height:100%}
|
||||||
|
:focus-visible{outline:2px solid var(--cyan);outline-offset:2px}
|
||||||
|
.cbt{display:grid;grid-template-columns:1fr 220px;gap:14px}
|
||||||
|
.timer{font-variant-numeric:tabular-nums;color:var(--amber);font-weight:600}
|
||||||
|
.kv{display:grid;grid-template-columns:repeat(4,1fr);gap:12px;margin:14px 0}
|
||||||
|
.kv .card b{display:block;font-size:1.5rem;color:#fff;font-variant-numeric:tabular-nums}
|
||||||
|
footer{margin-top:48px;border-top:1px solid var(--border);padding-top:16px;color:var(--dim);font-size:.75rem;display:flex;justify-content:space-between;flex-wrap:wrap;gap:8px}
|
||||||
|
/* Tablet: the CBT palette drops under the question instead of squeezing it. */
|
||||||
|
@media(max-width:1024px){.cbt{grid-template-columns:1fr}.palette{grid-template-columns:repeat(8,1fr)}}
|
||||||
|
/* Phone: one column throughout, a 2x2 metric grid and full-width palette taps. */
|
||||||
|
@media(max-width:700px){.grid2,.grid3{grid-template-columns:1fr}.kv{grid-template-columns:1fr 1fr}h1{font-size:1.6rem}.nav a.hidem{display:none}.palette{grid-template-columns:repeat(5,1fr)}.cbt{gap:10px}.topbar{padding:10px 0}.brand span{display:none}}
|
||||||
|
@media(max-width:420px){.kv{grid-template-columns:1fr}.palette{grid-template-columns:repeat(4,1fr)}.brand{font-size:.9rem}.btn{padding:10px 14px;min-height:44px}}
|
||||||
|
@media(hover:none){.pal{min-height:48px}}
|
||||||
23
tests/form_decode.rs
Normal file
23
tests/form_decode.rs
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
#[derive(Deserialize, Debug)]
|
||||||
|
struct Repeated {
|
||||||
|
chapters: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn repeated_keys_do_not_decode_into_vec() {
|
||||||
|
// Documents the behaviour that forced comma-separated ids on the build form.
|
||||||
|
let out = serde_urlencoded::from_str::<Repeated>("chapters=1&chapters=4");
|
||||||
|
assert!(out.is_err(), "serde_urlencoded keeps the last repeated key only");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn comma_separated_ids_decode() {
|
||||||
|
#[derive(Deserialize, Debug)]
|
||||||
|
struct Cs {
|
||||||
|
chapters: String,
|
||||||
|
}
|
||||||
|
let v = serde_urlencoded::from_str::<Cs>("chapters=1,4,7").expect("decodes");
|
||||||
|
assert_eq!(v.chapters, "1,4,7");
|
||||||
|
}
|
||||||
95
tests/grading.rs
Normal file
95
tests/grading.rs
Normal file
|
|
@ -0,0 +1,95 @@
|
||||||
|
// Grading rules: MCQ comparison, numerical tolerance, and the +4 / −1 scheme.
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
|
struct Q {
|
||||||
|
qtype: &'static str,
|
||||||
|
answer: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn numerical_ok(selected: &str, answer: &str) -> bool {
|
||||||
|
let s: f64 = match selected.trim().parse() {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(_) => return false,
|
||||||
|
};
|
||||||
|
let a: f64 = match answer.trim().parse() {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(_) => return false,
|
||||||
|
};
|
||||||
|
let tol = a.abs() * 1e-3 + 1e-6;
|
||||||
|
(s - a).abs() <= tol
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_correct(q: &Q, selected: &str) -> bool {
|
||||||
|
if selected.trim().is_empty() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if q.qtype == "NUM" {
|
||||||
|
numerical_ok(selected, &q.answer)
|
||||||
|
} else {
|
||||||
|
selected.trim().eq_ignore_ascii_case(q.answer.trim())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn marks(q: &Q, selected: &str) -> i32 {
|
||||||
|
if selected.trim().is_empty() {
|
||||||
|
0
|
||||||
|
} else if is_correct(q, selected) {
|
||||||
|
4
|
||||||
|
} else {
|
||||||
|
-1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mcq_is_case_insensitive_and_trimmed() {
|
||||||
|
let q = Q { qtype: "MCQ", answer: "A".into() };
|
||||||
|
assert!(is_correct(&q, "a"));
|
||||||
|
assert!(is_correct(&q, " A "));
|
||||||
|
assert!(!is_correct(&q, "B"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn blank_answer_is_wrong_not_skipped() {
|
||||||
|
let q = Q { qtype: "MCQ", answer: "A".into() };
|
||||||
|
assert_eq!(marks(&q, ""), 0, "a blank question scores zero, not minus one");
|
||||||
|
assert_eq!(marks(&q, " "), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn wrong_answer_costs_one_mark() {
|
||||||
|
let q = Q { qtype: "MCQ", answer: "A".into() };
|
||||||
|
assert_eq!(marks(&q, "C"), -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn correct_answer_scores_four() {
|
||||||
|
let q = Q { qtype: "MCQ", answer: "A".into() };
|
||||||
|
assert_eq!(marks(&q, "A"), 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn numerical_answers_allow_small_float_error() {
|
||||||
|
let q = Q { qtype: "NUM", answer: "20".into() };
|
||||||
|
assert!(is_correct(&q, "20.0"));
|
||||||
|
assert!(is_correct(&q, "20.01"), "within 0.1 percent tolerance");
|
||||||
|
assert!(!is_correct(&q, "20.5"), "outside tolerance");
|
||||||
|
assert!(!is_correct(&q, "abc"), "non numeric input is wrong");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn zero_answer_is_not_mistaken_for_blank() {
|
||||||
|
let q = Q { qtype: "NUM", answer: "0".into() };
|
||||||
|
assert!(is_correct(&q, "0"));
|
||||||
|
assert_eq!(marks(&q, "0"), 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mixed_paper_score_matches_hand_calculation() {
|
||||||
|
let mut answers = HashMap::new();
|
||||||
|
answers.insert(1i64, ("A".to_string(), 4)); // correct
|
||||||
|
answers.insert(2i64, ("B".to_string(), -1)); // wrong
|
||||||
|
answers.insert(3i64, (String::new(), 0)); // blank
|
||||||
|
let total: i32 = answers.values().map(|(_, m)| m).sum();
|
||||||
|
assert_eq!(total, 3);
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue