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