diff --git a/Cargo.toml b/Cargo.toml index c0417af..1588248 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,4 +17,5 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] } sha256 = "1.1" jsonwebtoken = "8.3" tera = "1" +genpdf = "0.2" lazy_static = "1.4" diff --git a/fonts/Lato-Black.ttf b/fonts/Lato-Black.ttf new file mode 100644 index 0000000..4340502 Binary files /dev/null and b/fonts/Lato-Black.ttf differ diff --git a/fonts/Lato-BlackItalic.ttf b/fonts/Lato-BlackItalic.ttf new file mode 100644 index 0000000..4df1555 Binary files /dev/null and b/fonts/Lato-BlackItalic.ttf differ diff --git a/fonts/Lato-Bold.ttf b/fonts/Lato-Bold.ttf new file mode 100644 index 0000000..016068b Binary files /dev/null and b/fonts/Lato-Bold.ttf differ diff --git a/fonts/Lato-BoldItalic.ttf b/fonts/Lato-BoldItalic.ttf new file mode 100644 index 0000000..a05d503 Binary files /dev/null and b/fonts/Lato-BoldItalic.ttf differ diff --git a/fonts/Lato-Italic.ttf b/fonts/Lato-Italic.ttf new file mode 100644 index 0000000..0d0f69e Binary files /dev/null and b/fonts/Lato-Italic.ttf differ diff --git a/fonts/Lato-Light.ttf b/fonts/Lato-Light.ttf new file mode 100644 index 0000000..dfa72ce Binary files /dev/null and b/fonts/Lato-Light.ttf differ diff --git a/fonts/Lato-LightItalic.ttf b/fonts/Lato-LightItalic.ttf new file mode 100644 index 0000000..12f2b6c Binary files /dev/null and b/fonts/Lato-LightItalic.ttf differ diff --git a/fonts/Lato-Regular.ttf b/fonts/Lato-Regular.ttf new file mode 100644 index 0000000..bb2e887 Binary files /dev/null and b/fonts/Lato-Regular.ttf differ diff --git a/fonts/Lato-Thin.ttf b/fonts/Lato-Thin.ttf new file mode 100644 index 0000000..ba58da1 Binary files /dev/null and b/fonts/Lato-Thin.ttf differ diff --git a/fonts/Lato-ThinItalic.ttf b/fonts/Lato-ThinItalic.ttf new file mode 100644 index 0000000..4d82766 Binary files /dev/null and b/fonts/Lato-ThinItalic.ttf differ diff --git a/src/events.rs b/src/events.rs index 39764a6..0f16685 100644 --- a/src/events.rs +++ b/src/events.rs @@ -110,6 +110,53 @@ LIMIT .into_response() } +pub async fn get_recent_events( + AuthBearer(token): AuthBearer, + State(app_state): State, +) -> impl IntoResponse { + let token_data = match handle_token(token, &app_state, Role::Student) { + Ok(token_data) => token_data, + Err(err) => return err, + }; + + let result = query_as!( + Event, + r#" + SELECT + e.id, + e.title, + e.description, + e.time_start, + e.time_end, + e.event_type AS "event_type!: EventType", + e.points, + e.place, + e.price, + e.created_by + FROM events e + INNER JOIN event_attendees ea + ON ea.event_id = e.id + WHERE + ea.user_id = $1 + ; + "#, + token_data.id + ) + .fetch_all(&app_state.db_pool) + .await; + + match result { + Ok(events) => (StatusCode::OK, Json(json!(events))), + Err(err) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ + "error": format!("Unknown error getting events: {:?}", err) + })), + ), + } + .into_response() +} + pub async fn get_event( AuthBearer(token): AuthBearer, State(app_state): State, diff --git a/src/leaderboard.rs b/src/leaderboard.rs new file mode 100644 index 0000000..b266744 --- /dev/null +++ b/src/leaderboard.rs @@ -0,0 +1,88 @@ +use axum::{extract::State, http::StatusCode, response::IntoResponse, Json}; +use axum_auth::AuthBearer; +use serde_json::json; +use sqlx::{query, query_as}; + +use crate::{ + jwt::handle_token, + models::{LeaderBoardEntry, Role}, + AppState, +}; + +pub async fn get_points( + AuthBearer(token): AuthBearer, + State(app_state): State, +) -> impl IntoResponse { + let token_data = match handle_token(token, &app_state, Role::Student) { + Ok(token_data) => token_data, + Err(err) => return err, + }; + + let result = query!( + r#" + SELECT + COALESCE(SUM(e.points), 0) AS points + FROM events e + INNER JOIN event_attendees ea + ON ea.event_id = e.id + WHERE + ea.user_id = $1 AND + ea.confirmed = true + ; + "#, + token_data.id + ) + .fetch_one(&app_state.db_pool) + .await; + + match result { + Ok(record) => (StatusCode::OK, Json(json!({ "data": record.points }))), + Err(err) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ + "error": format!("Unknown error getting events: {:?}", err) + })), + ), + } + .into_response() +} + +pub async fn list_points( + AuthBearer(token): AuthBearer, + State(app_state): State, +) -> impl IntoResponse { + if let Err(err) = handle_token(token, &app_state, Role::Student) { + return err; + }; + + let result = query_as!( + LeaderBoardEntry, + r#" + SELECT + u.username, + COALESCE(SUM(e.points), 0) AS points + FROM + users u + LEFT JOIN event_attendees ea + ON u.id = ea.user_id AND ea.confirmed = true + LEFT JOIN events e + ON ea.event_id = e.id + GROUP BY u.id + ORDER BY points + ; + "#, + ) + .fetch_all(&app_state.db_pool) + .await; + + match result { + Ok(point_totals) => (StatusCode::OK, Json(json!({ "data": point_totals }))), + Err(err) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ + "error": format!("Unknown error getting events: {:?}", err) + })), + ), + } + .into_response() +} diff --git a/src/main.rs b/src/main.rs index 15ab68c..fcebfa0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,6 +1,7 @@ mod attending; mod events; mod jwt; +mod leaderboard; mod models; mod report; mod user; @@ -46,10 +47,13 @@ async fn main() { .route("/event", get(events::get_event).post(events::create_event)) .route("/event/preview", get(events::get_events_preview)) .route("/event/future", get(events::get_all_events)) + .route("/event/recent", get(events::get_recent_events)) .route("/report", get(report::get_report)) .route("/attending/confirm", put(attending::confirm_attending)) .route("/attending/mark", post(attending::mark_attending)) .route("/attending/unmark", delete(attending::unmark_attending)) + .route("/leaderboard/my_points", get(leaderboard::get_points)) + .route("/leaderboard/list_points", get(leaderboard::list_points)) .with_state(AppState { db_pool, jwt_encode, diff --git a/src/models.rs b/src/models.rs index 12481aa..ad56df5 100644 --- a/src/models.rs +++ b/src/models.rs @@ -116,11 +116,10 @@ pub struct Claims { #[derive( sqlx::Type, Clone, Hash, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, )] -pub struct UserReportEntry { +pub struct LeaderBoardEntry { pub username: String, - pub points: i32, + pub points: Option, } - /// Module for (de)serializing [OffsetDateTime] to conform with the JWT spec (RFC 7519 section 2, "Numeric Date") mod serde_numeric_date { use serde::{self, Deserialize, Deserializer, Serializer}; diff --git a/src/report.rs b/src/report.rs index aeb0a91..53850ad 100644 --- a/src/report.rs +++ b/src/report.rs @@ -1,6 +1,6 @@ use crate::{ jwt::handle_token, - models::{ReportQuery, Role, UserReportEntry}, + models::{LeaderBoardEntry, ReportQuery, Role}, AppState, }; use axum::{ @@ -10,6 +10,12 @@ use axum::{ Json, }; use axum_auth::AuthBearer; +use genpdf::{ + elements::{self, TableLayout}, + fonts::{self, FontFamily}, + style::{Style, StyledString}, + Document, +}; use lazy_static::lazy_static; use serde_json::json; use sqlx::{query_as, types::chrono::Local}; @@ -40,11 +46,11 @@ pub async fn get_report( }; let result = query_as!( - UserReportEntry, + LeaderBoardEntry, r#" SELECT u.username, - COALESCE(SUM(e.points), 0) AS "points!: i32" + COALESCE(SUM(e.points), 0) AS points FROM users u LEFT JOIN event_attendees ea @@ -53,7 +59,8 @@ pub async fn get_report( ON ea.event_id = e.id WHERE u.grade = $1 OR $2 - GROUP BY u.id; + GROUP BY u.id + ORDER BY points "#, report_query.grade.unwrap_or(0), report_query.grade.is_none(), @@ -67,13 +74,62 @@ pub async fn get_report( return ( StatusCode::BAD_REQUEST, Json(json!({ - "error": format!("Unknown error creating event: {}", err) + "error": format!("Unknown error creating event: {:?}", err) })), ) .into_response() } }; + // Create a new PDF document + let font_family = match fonts::from_files("./fonts", "Lato", None) { + Ok(ff) => ff, + Err(err) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ + "error": format!("Unknown error rendering pdf: {:?}", err) + })), + ) + .into_response() + } + }; + let mut pdf = Document::new(font_family); + + // Add the current date and the report title to the page + let current_date = Local::now().format("%B %e, %Y").to_string(); + pdf.write_text(¤t_date, &title_font, 16.0, 30.0, None)?; + let title_text = match report_query.grade { + Some(grade) => format!("Student Point Report Grade {}", grade), + None => "Student Point Report".to_owned(), + }; + + pdf.set_title(title_text); + let heading_style = Style::new().with_font_size(24).bold(); + let heading = elements::Text::new(StyledString::new(title_text, heading_style)); + pdf.push(heading); + + // Define the table headers + let headers = vec!["Name", "Points"]; + + // Define the table data + let data = users + .iter() + .map(|(name, points)| vec![name.to_string(), points.to_string()]) + .collect(); + + // Define the table builder and set some options + let table_builder = PdfTableBuilder::new(&page, 10.0, 80.0, 170.0, headers.len() as u32) + .set_font(&header_font, 12.0) + .set_text_alignment(genpdf::Alignment::Center) + .set_row_height(20.0) + .set_header_bg_color((200, 200, 200)) + .set_header_text_color((0, 0, 0)) + .set_alternate_row_bg_color((240, 240, 240)); + + // Build the table and add it to the page + let table = table_builder.build_table(headers, data, &body_font)?; + page.add_table(table); // render report to html. let mut context = Context::new(); context.insert("grade", &report_query.grade); @@ -97,3 +153,70 @@ pub async fn get_report( println!("{}", report); (StatusCode::OK, "").into_response() } + +fn render_pdf( + grade: Option, + data: Vec, +) -> Result { + let font_family = fonts::from_files("./fonts", "Lato", None)?; + let mut pdf = Document::new(font_family); + + // Add the current date and the report title to the page + let current_date = Local::now().format("%Y-%m-%d").to_string(); + let title_text = match grade { + Some(grade) => format!("Student Point Report Grade {}", grade), + None => "Student Point Report".to_owned(), + }; + + pdf.set_title(title_text); + let heading_style = Style::new().with_font_size(24).bold(); + let heading = elements::Text::new(StyledString::new(title_text, heading_style)); + pdf.push(heading); + + let date_text = elements::Text::new(current_date); + pdf.push(date_text); + + // Define the table headers + let headers = vec!["Name", "Points"]; + + // Define the table builder and set some options + let mut table = TableLayout::new(vec![2, 1]) + .set_cell_decorator(elements::FrameCellDecorator::new(true, true, true)); + for row_data in data { + table + .row() + .element(elements::Paragraph::new(row_data.username)) + .element(elements::Paragraph::new( + row_data.points.unwrap().to_string(), + )) + .push()?; + } + let table_builder = TableLayout::new(&page, 10.0, 80.0, 170.0, headers.len() as u32) + .set_font(&header_font, 12.0) + .set_text_alignment(genpdf::Alignment::Center) + .set_row_height(20.0) + .set_header_bg_color((200, 200, 200)) + .set_header_text_color((0, 0, 0)) + .set_alternate_row_bg_color((240, 240, 240)); + + // Build the table and add it to the page + let table = table_builder.build_table(headers, data, &body_font)?; + page.add_table(table); + // render report to html. + let mut context = Context::new(); + context.insert("grade", &report_query.grade); + context.insert("date", &Local::now().format("%Y-%m-%d").to_string()); + context.insert("users", &records); + let report = match TEMPLATES.render("report.html", &context) { + Ok(report) => report, + Err(err) => { + return ( + StatusCode::BAD_REQUEST, + Json(json!({ + "error": format!("Unknown error creating event: {:?}", err) + })), + ) + .into_response() + } + }; +}