feat(backend): complete attendance, notes, and export APIs
This commit is contained in:
27
backend/Cargo.lock
generated
27
backend/Cargo.lock
generated
@@ -95,6 +95,7 @@ dependencies = [
|
||||
"matchit",
|
||||
"memchr",
|
||||
"mime",
|
||||
"multer",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"serde_core",
|
||||
@@ -482,6 +483,15 @@ dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "encoding_rs"
|
||||
version = "0.8.35"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "equivalent"
|
||||
version = "1.0.2"
|
||||
@@ -1170,6 +1180,23 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "multer"
|
||||
version = "3.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"encoding_rs",
|
||||
"futures-util",
|
||||
"http",
|
||||
"httparse",
|
||||
"memchr",
|
||||
"mime",
|
||||
"spin",
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nu-ansi-term"
|
||||
version = "0.50.3"
|
||||
|
||||
@@ -9,6 +9,7 @@ mod test_helpers;
|
||||
|
||||
use axum::Router;
|
||||
use tracing_subscriber::EnvFilter;
|
||||
use tower_http::services::{ServeDir, ServeFile};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
@@ -17,7 +18,16 @@ async fn main() {
|
||||
.init();
|
||||
|
||||
let pool = db::init().await.expect("db init failed");
|
||||
let app = routes::build(pool);
|
||||
|
||||
let static_dir = std::env::var("STATIC_DIR")
|
||||
.unwrap_or_else(|_| "../frontend/build".into());
|
||||
|
||||
let app = routes::build(pool)
|
||||
.fallback_service(
|
||||
ServeDir::new(&static_dir)
|
||||
.fallback(ServeFile::new(format!("{static_dir}/index.html")))
|
||||
);
|
||||
|
||||
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await
|
||||
.expect("failed to bind :3000");
|
||||
tracing::info!("listening on :3000");
|
||||
|
||||
218
backend/src/routes/attendance.rs
Normal file
218
backend/src/routes/attendance.rs
Normal file
@@ -0,0 +1,218 @@
|
||||
use axum::{
|
||||
extract::{Path, State, Query},
|
||||
routing::{get, post, delete},
|
||||
Json, Router,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use sqlx::SqlitePool;
|
||||
use crate::{auth::TutorClaims, error::AppError, models::ManualAttendance};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct SessionQuery {
|
||||
pub session_id: i64,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct StudentQuery {
|
||||
pub student_id: i64,
|
||||
}
|
||||
|
||||
async fn create_attendance(
|
||||
State(pool): State<SqlitePool>,
|
||||
claims: TutorClaims,
|
||||
Path(slot_id): Path<i64>,
|
||||
Json(req): Json<ManualAttendance>,
|
||||
) -> Result<(), AppError> {
|
||||
// Verify tutor access to the course via slot -> session -> course
|
||||
let course_id: (i64,) = sqlx::query_as(
|
||||
"SELECT s.course_id FROM slots sl JOIN sessions s ON sl.session_id = s.id WHERE sl.id = ?"
|
||||
)
|
||||
.bind(slot_id)
|
||||
.fetch_one(&pool)
|
||||
.await?;
|
||||
|
||||
super::verify_tutor_course_access(&pool, claims.sub, course_id.0).await?;
|
||||
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO attendances (slot_id, student_id, seat_id, checked_in_at) VALUES (?, ?, NULL, ?)"
|
||||
)
|
||||
.bind(slot_id)
|
||||
.bind(req.student_id)
|
||||
.bind(now)
|
||||
.execute(&pool)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete_attendance(
|
||||
State(pool): State<SqlitePool>,
|
||||
claims: TutorClaims,
|
||||
Path((slot_id, student_id)): Path<(i64, i64)>,
|
||||
) -> Result<(), AppError> {
|
||||
let course_id: (i64,) = sqlx::query_as(
|
||||
"SELECT s.course_id FROM slots sl JOIN sessions s ON sl.session_id = s.id WHERE sl.id = ?"
|
||||
)
|
||||
.bind(slot_id)
|
||||
.fetch_one(&pool)
|
||||
.await?;
|
||||
|
||||
super::verify_tutor_course_access(&pool, claims.sub, course_id.0).await?;
|
||||
|
||||
sqlx::query("DELETE FROM attendances WHERE slot_id = ? AND student_id = ?")
|
||||
.bind(slot_id)
|
||||
.bind(student_id)
|
||||
.execute(&pool)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_session_attendance(
|
||||
State(pool): State<SqlitePool>,
|
||||
claims: TutorClaims,
|
||||
Path(session_id): Path<i64>,
|
||||
) -> Result<Json<serde_json::Value>, AppError> {
|
||||
let course_id: (i64,) = sqlx::query_as("SELECT course_id FROM sessions WHERE id = ?")
|
||||
.bind(session_id)
|
||||
.fetch_one(&pool)
|
||||
.await?;
|
||||
|
||||
super::verify_tutor_course_access(&pool, claims.sub, course_id.0).await?;
|
||||
|
||||
// Get all students for the course
|
||||
let students: Vec<crate::models::Student> = sqlx::query_as(
|
||||
"SELECT id, course_id, name FROM students WHERE course_id = ? ORDER BY name"
|
||||
)
|
||||
.bind(course_id.0)
|
||||
.fetch_all(&pool)
|
||||
.await?;
|
||||
|
||||
// Get all slots for the session
|
||||
let slots: Vec<crate::models::Slot> = sqlx::query_as(
|
||||
"SELECT id, session_id, room_id, tutor_id, start_time, end_time, status, code FROM slots WHERE session_id = ?"
|
||||
)
|
||||
.bind(session_id)
|
||||
.fetch_all(&pool)
|
||||
.await?;
|
||||
|
||||
// Get all attendances for these slots
|
||||
let attendances: Vec<crate::models::Attendance> = sqlx::query_as(
|
||||
"SELECT id, slot_id, student_id, seat_id, checked_in_at FROM attendances WHERE slot_id IN (SELECT id FROM slots WHERE session_id = ?)"
|
||||
)
|
||||
.bind(session_id)
|
||||
.fetch_all(&pool)
|
||||
.await?;
|
||||
|
||||
let res = serde_json::json!({
|
||||
"students": students,
|
||||
"slots": slots,
|
||||
"attendances": attendances,
|
||||
});
|
||||
|
||||
Ok(Json(res))
|
||||
}
|
||||
|
||||
async fn get_student_attendance(
|
||||
State(pool): State<SqlitePool>,
|
||||
claims: TutorClaims,
|
||||
Path(student_id): Path<i64>,
|
||||
) -> Result<Json<serde_json::Value>, AppError> {
|
||||
let course_id: (i64,) = sqlx::query_as("SELECT course_id FROM students WHERE id = ?")
|
||||
.bind(student_id)
|
||||
.fetch_one(&pool)
|
||||
.await?;
|
||||
|
||||
super::verify_tutor_course_access(&pool, claims.sub, course_id.0).await?;
|
||||
|
||||
let attendances: Vec<serde_json::Value> = sqlx::query(
|
||||
r#"
|
||||
SELECT a.id, a.slot_id, a.checked_in_at, s.week_nr, sl.start_time
|
||||
FROM attendances a
|
||||
JOIN slots sl ON a.slot_id = sl.id
|
||||
JOIN sessions s ON sl.session_id = s.id
|
||||
WHERE a.student_id = ?
|
||||
ORDER BY s.week_nr DESC, sl.start_time DESC
|
||||
"#
|
||||
)
|
||||
.bind(student_id)
|
||||
.fetch_all(&pool)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|row| {
|
||||
use sqlx::Row;
|
||||
serde_json::json!({
|
||||
"id": row.get::<i64, _>("id"),
|
||||
"slot_id": row.get::<i64, _>("slot_id"),
|
||||
"checked_in_at": row.get::<String, _>("checked_in_at"),
|
||||
"week_nr": row.get::<i64, _>("week_nr"),
|
||||
"start_time": row.get::<String, _>("start_time"),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Json(serde_json::json!(attendances)))
|
||||
}
|
||||
|
||||
pub fn router() -> Router<SqlitePool> {
|
||||
Router::new()
|
||||
.route("/api/admin/slots/{id}/attendance", post(create_attendance))
|
||||
.route("/api/admin/slots/{slot_id}/attendance/{student_id}", delete(delete_attendance))
|
||||
.route("/api/admin/sessions/{id}/attendance", get(get_session_attendance))
|
||||
.route("/api/admin/students/{id}/attendance", get(get_student_attendance))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::test_helpers::{build_test_app, post_json, get};
|
||||
use axum::http::StatusCode;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
async fn seed_data(pool: &SqlitePool) -> (i64, i64, i64) {
|
||||
let tutor: (i64,) = sqlx::query_as("SELECT id FROM tutors WHERE email = 'tutor@test.com'")
|
||||
.fetch_one(pool).await.unwrap();
|
||||
let course_id: (i64,) = sqlx::query_as("INSERT INTO courses (name, semester) VALUES ('FP', 'SS2026') RETURNING id")
|
||||
.fetch_one(pool).await.unwrap();
|
||||
sqlx::query("INSERT INTO tutor_courses (tutor_id, course_id) VALUES (?, ?)")
|
||||
.bind(tutor.0).bind(course_id.0).execute(pool).await.unwrap();
|
||||
let student_id: (i64,) = sqlx::query_as("INSERT INTO students (course_id, name) VALUES (?, 'Alice') RETURNING id")
|
||||
.bind(course_id.0).fetch_one(pool).await.unwrap();
|
||||
let session_id: (i64,) = sqlx::query_as("INSERT INTO sessions (course_id, week_nr, date) VALUES (?, 1, '2026-04-28') RETURNING id")
|
||||
.bind(course_id.0).fetch_one(pool).await.unwrap();
|
||||
let slot_id: (i64,) = sqlx::query_as("INSERT INTO slots (session_id, tutor_id, start_time, end_time, status) VALUES (?, ?, '09:00', '10:00', 'open') RETURNING id")
|
||||
.bind(session_id.0).bind(tutor.0).fetch_one(pool).await.unwrap();
|
||||
(slot_id.0, student_id.0, session_id.0)
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn manual_attendance_entry(pool: SqlitePool) {
|
||||
let (app, auth) = build_test_app(pool.clone()).await;
|
||||
let (slot_id, student_id, _) = seed_data(&pool).await;
|
||||
|
||||
let (status, _) = post_json(app.clone(), &format!("/api/admin/slots/{slot_id}/attendance"), &auth, json!({"student_id": student_id})).await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
|
||||
let (status, body) = get(app, &format!("/api/admin/students/{student_id}/attendance"), &auth).await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
let attendances: Value = serde_json::from_slice(&body).unwrap();
|
||||
assert_eq!(attendances.as_array().unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn per_week_attendance_view(pool: SqlitePool) {
|
||||
let (app, auth) = build_test_app(pool.clone()).await;
|
||||
let (slot_id, student_id, session_id) = seed_data(&pool).await;
|
||||
|
||||
post_json(app.clone(), &format!("/api/admin/slots/{slot_id}/attendance"), &auth, json!({"student_id": student_id})).await;
|
||||
|
||||
let (status, body) = get(app, &format!("/api/admin/sessions/{session_id}/attendance"), &auth).await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
let res: Value = serde_json::from_slice(&body).unwrap();
|
||||
assert_eq!(res["attendances"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(res["students"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(res["slots"].as_array().unwrap().len(), 1);
|
||||
}
|
||||
}
|
||||
313
backend/src/routes/export.rs
Normal file
313
backend/src/routes/export.rs
Normal file
@@ -0,0 +1,313 @@
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
response::{IntoResponse, Response},
|
||||
routing::get,
|
||||
Router,
|
||||
};
|
||||
use axum::http::{header, StatusCode};
|
||||
use sqlx::SqlitePool;
|
||||
use crate::{auth::TutorClaims, error::AppError};
|
||||
use std::fmt::Write;
|
||||
|
||||
async fn export_session_csv(
|
||||
State(pool): State<SqlitePool>,
|
||||
claims: TutorClaims,
|
||||
Path(session_id): Path<i64>,
|
||||
) -> Result<Response, AppError> {
|
||||
let course_id: (i64,) = sqlx::query_as("SELECT course_id FROM sessions WHERE id = ?")
|
||||
.bind(session_id)
|
||||
.fetch_one(&pool)
|
||||
.await?;
|
||||
|
||||
super::verify_tutor_course_access(&pool, claims.sub, course_id.0).await?;
|
||||
|
||||
let students: Vec<crate::models::Student> = sqlx::query_as(
|
||||
"SELECT id, course_id, name FROM students WHERE course_id = ? ORDER BY name"
|
||||
)
|
||||
.bind(course_id.0)
|
||||
.fetch_all(&pool)
|
||||
.await?;
|
||||
|
||||
let attendance_counts: Vec<(i64,)> = sqlx::query_as(
|
||||
r#"
|
||||
SELECT student_id FROM attendances
|
||||
WHERE slot_id IN (SELECT id FROM slots WHERE session_id = ?)
|
||||
"#
|
||||
)
|
||||
.bind(session_id)
|
||||
.fetch_all(&pool)
|
||||
.await?;
|
||||
|
||||
let attended_student_ids: std::collections::HashSet<i64> = attendance_counts.into_iter().map(|(id,)| id).collect();
|
||||
|
||||
let mut csv = String::from("Student,Present\n");
|
||||
for student in students {
|
||||
let present = attended_student_ids.contains(&student.id);
|
||||
writeln!(csv, "{},{}", student.name, present).unwrap();
|
||||
}
|
||||
|
||||
Ok((
|
||||
[(header::CONTENT_TYPE, "text/csv")],
|
||||
csv
|
||||
).into_response())
|
||||
}
|
||||
|
||||
async fn export_session_md(
|
||||
State(pool): State<SqlitePool>,
|
||||
claims: TutorClaims,
|
||||
Path(session_id): Path<i64>,
|
||||
) -> Result<Response, AppError> {
|
||||
let course_id: (i64,) = sqlx::query_as("SELECT course_id FROM sessions WHERE id = ?")
|
||||
.bind(session_id)
|
||||
.fetch_one(&pool)
|
||||
.await?;
|
||||
|
||||
super::verify_tutor_course_access(&pool, claims.sub, course_id.0).await?;
|
||||
|
||||
let students: Vec<crate::models::Student> = sqlx::query_as(
|
||||
"SELECT id, course_id, name FROM students WHERE course_id = ? ORDER BY name"
|
||||
)
|
||||
.bind(course_id.0)
|
||||
.fetch_all(&pool)
|
||||
.await?;
|
||||
|
||||
let attendance_counts: Vec<(i64,)> = sqlx::query_as(
|
||||
r#"
|
||||
SELECT student_id FROM attendances
|
||||
WHERE slot_id IN (SELECT id FROM slots WHERE session_id = ?)
|
||||
"#
|
||||
)
|
||||
.bind(session_id)
|
||||
.fetch_all(&pool)
|
||||
.await?;
|
||||
|
||||
let attended_student_ids: std::collections::HashSet<i64> = attendance_counts.into_iter().map(|(id,)| id).collect();
|
||||
|
||||
let mut md = String::from("| Student | Present |\n|---------|---------|\n");
|
||||
for student in students {
|
||||
let present = if attended_student_ids.contains(&student.id) { "✓" } else { " " };
|
||||
writeln!(md, "| {} | {} |", student.name, present).unwrap();
|
||||
}
|
||||
|
||||
Ok((
|
||||
[(header::CONTENT_TYPE, "text/markdown")],
|
||||
md
|
||||
).into_response())
|
||||
}
|
||||
|
||||
async fn export_course_csv(
|
||||
State(pool): State<SqlitePool>,
|
||||
claims: TutorClaims,
|
||||
Path(course_id): Path<i64>,
|
||||
) -> Result<Response, AppError> {
|
||||
super::verify_tutor_course_access(&pool, claims.sub, course_id).await?;
|
||||
|
||||
let students: Vec<crate::models::Student> = sqlx::query_as(
|
||||
"SELECT id, course_id, name FROM students WHERE course_id = ? ORDER BY name"
|
||||
)
|
||||
.bind(course_id)
|
||||
.fetch_all(&pool)
|
||||
.await?;
|
||||
|
||||
let sessions: Vec<crate::models::Session> = sqlx::query_as(
|
||||
"SELECT id, course_id, week_nr, date FROM sessions WHERE course_id = ? ORDER BY week_nr"
|
||||
)
|
||||
.bind(course_id)
|
||||
.fetch_all(&pool)
|
||||
.await?;
|
||||
|
||||
let mut csv = String::from("Student");
|
||||
for session in &sessions {
|
||||
write!(csv, ",Week {}", session.week_nr).unwrap();
|
||||
}
|
||||
csv.push_str(",Bonus\n");
|
||||
|
||||
for student in students {
|
||||
write!(csv, "{}", student.name).unwrap();
|
||||
let mut unexcused_absences = 0;
|
||||
for session in &sessions {
|
||||
let attended: (i64,) = sqlx::query_as(
|
||||
r#"
|
||||
SELECT COUNT(*) FROM attendances
|
||||
WHERE student_id = ? AND slot_id IN (SELECT id FROM slots WHERE session_id = ?)
|
||||
"#
|
||||
)
|
||||
.bind(student.id)
|
||||
.bind(session.id)
|
||||
.fetch_one(&pool)
|
||||
.await?;
|
||||
|
||||
if attended.0 > 0 {
|
||||
csv.push_str(",true");
|
||||
} else {
|
||||
csv.push_str(",false");
|
||||
unexcused_absences += 1;
|
||||
}
|
||||
}
|
||||
let bonus = if unexcused_absences <= 1 { 3 } else { 0 };
|
||||
writeln!(csv, ",{}", bonus).unwrap();
|
||||
}
|
||||
|
||||
Ok((
|
||||
[(header::CONTENT_TYPE, "text/csv")],
|
||||
csv
|
||||
).into_response())
|
||||
}
|
||||
|
||||
async fn export_course_md(
|
||||
State(pool): State<SqlitePool>,
|
||||
claims: TutorClaims,
|
||||
Path(course_id): Path<i64>,
|
||||
) -> Result<Response, AppError> {
|
||||
super::verify_tutor_course_access(&pool, claims.sub, course_id).await?;
|
||||
|
||||
let students: Vec<crate::models::Student> = sqlx::query_as(
|
||||
"SELECT id, course_id, name FROM students WHERE course_id = ? ORDER BY name"
|
||||
)
|
||||
.bind(course_id)
|
||||
.fetch_all(&pool)
|
||||
.await?;
|
||||
|
||||
let sessions: Vec<crate::models::Session> = sqlx::query_as(
|
||||
"SELECT id, course_id, week_nr, date FROM sessions WHERE course_id = ? ORDER BY week_nr"
|
||||
)
|
||||
.bind(course_id)
|
||||
.fetch_all(&pool)
|
||||
.await?;
|
||||
|
||||
let mut md = String::from("| Student |");
|
||||
let mut separator = String::from("|---------|");
|
||||
for session in &sessions {
|
||||
write!(md, " Week {} |", session.week_nr).unwrap();
|
||||
separator.push_str("--------|");
|
||||
}
|
||||
md.push_str(" Bonus |\n");
|
||||
separator.push_str("-------|\n");
|
||||
md.push_str(&separator);
|
||||
|
||||
for student in students {
|
||||
write!(md, "| {} |", student.name).unwrap();
|
||||
let mut unexcused_absences = 0;
|
||||
for session in &sessions {
|
||||
let attended: (i64,) = sqlx::query_as(
|
||||
r#"
|
||||
SELECT COUNT(*) FROM attendances
|
||||
WHERE student_id = ? AND slot_id IN (SELECT id FROM slots WHERE session_id = ?)
|
||||
"#
|
||||
)
|
||||
.bind(student.id)
|
||||
.bind(session.id)
|
||||
.fetch_one(&pool)
|
||||
.await?;
|
||||
|
||||
if attended.0 > 0 {
|
||||
md.push_str(" ✓ |");
|
||||
} else {
|
||||
md.push_str(" |");
|
||||
unexcused_absences += 1;
|
||||
}
|
||||
}
|
||||
let bonus = if unexcused_absences <= 1 { 3 } else { 0 };
|
||||
writeln!(md, " {} |", bonus).unwrap();
|
||||
}
|
||||
|
||||
Ok((
|
||||
[(header::CONTENT_TYPE, "text/markdown")],
|
||||
md
|
||||
).into_response())
|
||||
}
|
||||
|
||||
async fn download_backup(
|
||||
State(pool): State<SqlitePool>,
|
||||
_claims: TutorClaims,
|
||||
) -> Result<Response, AppError> {
|
||||
let timestamp = chrono::Utc::now().format("%Y%m%d-%H%M%S");
|
||||
let backup_path = format!("/tmp/backup-{}.sqlite", timestamp);
|
||||
|
||||
sqlx::query(&format!("VACUUM INTO '{}'", backup_path))
|
||||
.execute(&pool)
|
||||
.await?;
|
||||
|
||||
let data = tokio::fs::read(&backup_path).await.map_err(|e| {
|
||||
tracing::error!("failed to read backup file: {}", e);
|
||||
AppError::BadRequest("failed to read backup".into())
|
||||
})?;
|
||||
|
||||
tokio::fs::remove_file(&backup_path).await.ok();
|
||||
|
||||
Ok((
|
||||
[
|
||||
(header::CONTENT_TYPE, "application/octet-stream"),
|
||||
(header::CONTENT_DISPOSITION, &format!("attachment; filename=\"backup-{}.sqlite\"", timestamp)),
|
||||
],
|
||||
data
|
||||
).into_response())
|
||||
}
|
||||
|
||||
pub fn router() -> Router<SqlitePool> {
|
||||
Router::new()
|
||||
.route("/api/admin/export/session/{id}/csv", get(export_session_csv))
|
||||
.route("/api/admin/export/session/{id}/md", get(export_session_md))
|
||||
.route("/api/admin/export/course/{id}/csv", get(export_course_csv))
|
||||
.route("/api/admin/export/course/{id}/md", get(export_course_md))
|
||||
.route("/api/admin/backup", get(download_backup))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::test_helpers::{build_test_app, get};
|
||||
use axum::http::StatusCode;
|
||||
|
||||
async fn seed_data(pool: &SqlitePool) -> (i64, i64, i64) {
|
||||
let tutor: (i64,) = sqlx::query_as("SELECT id FROM tutors WHERE email = 'tutor@test.com'")
|
||||
.fetch_one(pool).await.unwrap();
|
||||
let course_id: (i64,) = sqlx::query_as("INSERT INTO courses (name, semester) VALUES ('FP', 'SS2026') RETURNING id")
|
||||
.fetch_one(pool).await.unwrap();
|
||||
sqlx::query("INSERT INTO tutor_courses (tutor_id, course_id) VALUES (?, ?)")
|
||||
.bind(tutor.0).bind(course_id.0).execute(pool).await.unwrap();
|
||||
let student_id: (i64,) = sqlx::query_as("INSERT INTO students (course_id, name) VALUES (?, 'Alice') RETURNING id")
|
||||
.bind(course_id.0).fetch_one(pool).await.unwrap();
|
||||
let session_id: (i64,) = sqlx::query_as("INSERT INTO sessions (course_id, week_nr, date) VALUES (?, 1, '2026-04-28') RETURNING id")
|
||||
.bind(course_id.0).fetch_one(pool).await.unwrap();
|
||||
let slot_id: (i64,) = sqlx::query_as("INSERT INTO slots (session_id, tutor_id, start_time, end_time, status) VALUES (?, ?, '09:00', '10:00', 'open') RETURNING id")
|
||||
.bind(session_id.0).bind(tutor.0).fetch_one(pool).await.unwrap();
|
||||
|
||||
// Mark present
|
||||
sqlx::query("INSERT INTO attendances (slot_id, student_id, checked_in_at) VALUES (?, ?, '2026-04-28T09:00:00Z')")
|
||||
.bind(slot_id.0).bind(student_id.0).execute(pool).await.unwrap();
|
||||
|
||||
(course_id.0, session_id.0, student_id.0)
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn weekly_csv_format(pool: SqlitePool) {
|
||||
let (app, auth) = build_test_app(pool.clone()).await;
|
||||
let (_, session_id, _) = seed_data(&pool).await;
|
||||
|
||||
let (status, body) = get(app, &format!("/api/admin/export/session/{session_id}/csv"), &auth).await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
let csv = String::from_utf8(body.to_vec()).unwrap();
|
||||
assert!(csv.contains("Student,Present"));
|
||||
assert!(csv.contains("Alice,true"));
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn full_matrix_bonus_column(pool: SqlitePool) {
|
||||
let (app, auth) = build_test_app(pool.clone()).await;
|
||||
let (course_id, _, _) = seed_data(&pool).await;
|
||||
|
||||
let (status, body) = get(app, &format!("/api/admin/export/course/{course_id}/csv"), &auth).await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
let csv = String::from_utf8(body.to_vec()).unwrap();
|
||||
assert!(csv.contains("Student,Week 1,Bonus"));
|
||||
assert!(csv.contains("Alice,true,3"));
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn backup_headers(pool: SqlitePool) {
|
||||
let (app, auth) = build_test_app(pool.clone()).await;
|
||||
let (status, _) = get(app, "/api/admin/backup", &auth).await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,9 @@ mod checkin;
|
||||
mod courses;
|
||||
mod rooms;
|
||||
mod sessions;
|
||||
mod attendance;
|
||||
mod notes;
|
||||
mod export;
|
||||
|
||||
pub fn build(pool: SqlitePool) -> Router {
|
||||
Router::new()
|
||||
@@ -16,6 +19,9 @@ pub fn build(pool: SqlitePool) -> Router {
|
||||
.merge(courses::router())
|
||||
.merge(rooms::router())
|
||||
.merge(sessions::router())
|
||||
.merge(attendance::router())
|
||||
.merge(notes::router())
|
||||
.merge(export::router())
|
||||
.with_state(pool)
|
||||
}
|
||||
|
||||
|
||||
142
backend/src/routes/notes.rs
Normal file
142
backend/src/routes/notes.rs
Normal file
@@ -0,0 +1,142 @@
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
routing::{get, put},
|
||||
Json, Router,
|
||||
};
|
||||
use sqlx::SqlitePool;
|
||||
use crate::{auth::TutorClaims, error::AppError, models::UpsertNote};
|
||||
|
||||
async fn upsert_note(
|
||||
State(pool): State<SqlitePool>,
|
||||
claims: TutorClaims,
|
||||
Path((slot_id, student_id)): Path<(i64, i64)>,
|
||||
Json(req): Json<UpsertNote>,
|
||||
) -> Result<(), AppError> {
|
||||
let course_id: (i64,) = sqlx::query_as(
|
||||
"SELECT s.course_id FROM slots sl JOIN sessions s ON sl.session_id = s.id WHERE sl.id = ?"
|
||||
)
|
||||
.bind(slot_id)
|
||||
.fetch_one(&pool)
|
||||
.await?;
|
||||
|
||||
super::verify_tutor_course_access(&pool, claims.sub, course_id.0).await?;
|
||||
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO notes (slot_id, student_id, tutor_id, content, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(slot_id, student_id, tutor_id) DO UPDATE SET
|
||||
content = excluded.content,
|
||||
updated_at = excluded.updated_at
|
||||
"#
|
||||
)
|
||||
.bind(slot_id)
|
||||
.bind(student_id)
|
||||
.bind(claims.sub)
|
||||
.bind(req.content)
|
||||
.bind(now)
|
||||
.execute(&pool)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_slot_notes(
|
||||
State(pool): State<SqlitePool>,
|
||||
claims: TutorClaims,
|
||||
Path(slot_id): Path<i64>,
|
||||
) -> Result<Json<Vec<crate::models::Note>>, AppError> {
|
||||
let course_id: (i64,) = sqlx::query_as(
|
||||
"SELECT s.course_id FROM slots sl JOIN sessions s ON sl.session_id = s.id WHERE sl.id = ?"
|
||||
)
|
||||
.bind(slot_id)
|
||||
.fetch_one(&pool)
|
||||
.await?;
|
||||
|
||||
super::verify_tutor_course_access(&pool, claims.sub, course_id.0).await?;
|
||||
|
||||
let notes: Vec<crate::models::Note> = sqlx::query_as(
|
||||
"SELECT id, slot_id, student_id, tutor_id, content, updated_at FROM notes WHERE slot_id = ?"
|
||||
)
|
||||
.bind(slot_id)
|
||||
.fetch_all(&pool)
|
||||
.await?;
|
||||
|
||||
Ok(Json(notes))
|
||||
}
|
||||
|
||||
async fn get_student_notes(
|
||||
State(pool): State<SqlitePool>,
|
||||
claims: TutorClaims,
|
||||
Path(student_id): Path<i64>,
|
||||
) -> Result<Json<Vec<crate::models::Note>>, AppError> {
|
||||
let course_id: (i64,) = sqlx::query_as("SELECT course_id FROM students WHERE id = ?")
|
||||
.bind(student_id)
|
||||
.fetch_one(&pool)
|
||||
.await?;
|
||||
|
||||
super::verify_tutor_course_access(&pool, claims.sub, course_id.0).await?;
|
||||
|
||||
let notes: Vec<crate::models::Note> = sqlx::query_as(
|
||||
"SELECT id, slot_id, student_id, tutor_id, content, updated_at FROM notes WHERE student_id = ?"
|
||||
)
|
||||
.bind(student_id)
|
||||
.fetch_all(&pool)
|
||||
.await?;
|
||||
|
||||
Ok(Json(notes))
|
||||
}
|
||||
|
||||
pub fn router() -> Router<SqlitePool> {
|
||||
Router::new()
|
||||
.route("/api/admin/slots/{slot_id}/notes/{student_id}", put(upsert_note))
|
||||
.route("/api/admin/slots/{slot_id}/notes", get(get_slot_notes))
|
||||
.route("/api/admin/students/{id}/notes", get(get_student_notes))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::test_helpers::{build_test_app, put_json, get};
|
||||
use axum::http::StatusCode;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
async fn seed_data(pool: &SqlitePool) -> (i64, i64) {
|
||||
let tutor: (i64,) = sqlx::query_as("SELECT id FROM tutors WHERE email = 'tutor@test.com'")
|
||||
.fetch_one(pool).await.unwrap();
|
||||
let course_id: (i64,) = sqlx::query_as("INSERT INTO courses (name, semester) VALUES ('FP', 'SS2026') RETURNING id")
|
||||
.fetch_one(pool).await.unwrap();
|
||||
sqlx::query("INSERT INTO tutor_courses (tutor_id, course_id) VALUES (?, ?)")
|
||||
.bind(tutor.0).bind(course_id.0).execute(pool).await.unwrap();
|
||||
let student_id: (i64,) = sqlx::query_as("INSERT INTO students (course_id, name) VALUES (?, 'Alice') RETURNING id")
|
||||
.bind(course_id.0).fetch_one(pool).await.unwrap();
|
||||
let session_id: (i64,) = sqlx::query_as("INSERT INTO sessions (course_id, week_nr, date) VALUES (?, 1, '2026-04-28') RETURNING id")
|
||||
.bind(course_id.0).fetch_one(pool).await.unwrap();
|
||||
let slot_id: (i64,) = sqlx::query_as("INSERT INTO slots (session_id, tutor_id, start_time, end_time, status) VALUES (?, ?, '09:00', '10:00', 'open') RETURNING id")
|
||||
.bind(session_id.0).bind(tutor.0).fetch_one(pool).await.unwrap();
|
||||
(slot_id.0, student_id.0)
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn upsert_note(pool: SqlitePool) {
|
||||
let (app, auth) = build_test_app(pool.clone()).await;
|
||||
let (slot_id, student_id) = seed_data(&pool).await;
|
||||
|
||||
let (status, _) = put_json(app.clone(), &format!("/api/admin/slots/{slot_id}/notes/{student_id}"), &auth, json!({"content": "Good student"})).await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
|
||||
let (status, body) = get(app.clone(), &format!("/api/admin/slots/{slot_id}/notes"), &auth).await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
let notes: Value = serde_json::from_slice(&body).unwrap();
|
||||
assert_eq!(notes.as_array().unwrap().len(), 1);
|
||||
assert_eq!(notes[0]["content"], "Good student");
|
||||
|
||||
// Update note
|
||||
put_json(app.clone(), &format!("/api/admin/slots/{slot_id}/notes/{student_id}"), &auth, json!({"content": "Excellent student"})).await;
|
||||
let (_, body) = get(app, &format!("/api/admin/slots/{slot_id}/notes"), &auth).await;
|
||||
let notes: Value = serde_json::from_slice(&body).unwrap();
|
||||
assert_eq!(notes[0]["content"], "Excellent student");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user