diff --git a/Dockerfile b/Dockerfile index 77235d4..d298018 100644 --- a/Dockerfile +++ b/Dockerfile @@ -35,6 +35,9 @@ RUN apt-get update -y \ && apt-get clean -y \ && rm -rf /var/lib/apt/lists/* +RUN curl -L --proto '=https' --tlsv1.2 -sSf https://raw.githubusercontent.com/cargo-bins/cargo-binstall/main/install-from-binstall-release.sh | bash +RUN cargo binstall typst-cli -y + # Remember to replace project_name with the actual project name COPY --from=builder /app/target/release/scholarships-rs /app/ diff --git a/pdf_template.typ b/pdf_template.typ new file mode 100644 index 0000000..1c2a5dd --- /dev/null +++ b/pdf_template.typ @@ -0,0 +1,141 @@ +#set page(margin: 40pt) +#set block(breakable: false) + +#let map_block(header, val) = { + block( + width: 100%, + fill: rgb(200, 0, 0), + radius: 4pt, + below: 0pt, + above: 4pt, + inset: 2pt, + [ + #show heading: set text(white) + #show heading: set text(size: 15pt) + #stack(dir: ttb, + block( + inset: 7pt, + align(horizon)[= #header] + ), + align(center)[#block( + width: 100%, + fill: white, + inset: 8pt, + radius: 3pt, + align(left)[#val] + )], + ) + ] + ) +} + +#let info_line(subject, key, dict: student) = [ + + *#subject* #dict.at(key, default: "N/A") +] + +#let info_line_list(subject, key, dict: student) = [ + + *#subject* #dict.at(key, default: "N/A").join(", ") +] + +#show heading: set align(center) +#show heading: set text(size: 20pt) +#v(10cm) += #student.first_name #student.last_name +#show heading: set text(size: 17pt) +== Region 15 General Scholarship Application +#pagebreak() + +#show heading: set block(below: 20pt, above: 30pt) +#show heading: set text(size: 20pt) += Student Demographic Information + +#info_line("First Name:", "first_name") +#info_line("Last Name:", "last_name") +#info_line("Date of Birth:", "dob") +#info_line("Gender:", "gender") +#info_line("Phone Number:", "phone_number") +#info_line("Email:","email") +#info_line("Street Address:", "street_address") +#info_line("Town:", "town") + += Academic Information + +#info_line("Unweighted GPA:", "unweighted_gpa") +#info_line("Weighted GPA:", "weighted_gpa") +#info_line("Highest SAT Score:", "sat_score") +#info_line("Highest ACT Score:", "act_score") +#info_line("Academic Honors:", "academic_honors") + += Sports Participation +#if student.at("sports_participation", default: ()).len() == 0 [ + Student did not enter any sports participation. +] else { + for pair in student.sports_participation { + map_block(pair.at("sport_name"), [ + #info_line("Special Achievements:", "achievements", dict: pair) + #info_line_list("Grades Participated:", "grades", dict: pair) + ]) + } +} + += Extracurricular Activities +#if student.at("extracurricular", default: ()).len() == 0 [ + Student did not enter any extracurricular activities. +] else { + for pair in student.extracurricular { + map_block(pair.at("activity_name"), [ + #info_line("Number of hours per week:", "num_hours", dict: pair) + #info_line("Number of weeks participated:", "num_weeks", dict: pair) + #info_line("Special Involvement:", "special_involvement", dict: pair) + ]) + } +} + += Work Experience +#if student.at("work_experience", default: ()).len() == 0 [ + Student did not enter any work experience. +] else { + for pair in student.work_experience { + map_block(pair.at("employer"), [ + #info_line("Job Title:", "job_title", dict: pair) + #info_line("Start Date:", "start_date", dict: pair) + #info_line("End Date:", "end_date", dict: pair) + #info_line("Number of hours per week:", "num_hours", dict: pair) + ]) + } +} + += University Information + +#info_line("University Name:", "college_name") +#info_line("University Street Address:", "college_city") +#info_line("University State:", "college_state") +#info_line("University ZIP:", "college_zip") +#info_line("Has the student received an acceptance:", "college_acceptance") +#info_line("Student's chosen major:", "major") +#info_line("Student's intended career:", "intended_career") + += Family Information + +#info_line("Total number of children in student's family:", "num_children") +#info_line("Total number of children currently attending college:", "num_children_college") +#info_line("Parent/Guardian 1 Name:", "parent_one_name") +#info_line("Parent/Guardian 1 Relationship:", "parent_one_relationship") +#info_line("Parent/Guardian 1 Occupation:", "parent_one_occupation") +#info_line("Parent/Guardian 1 Employer:", "parent_one_employer") +#info_line("Parent/Guardian 2 Name:", "parent_two_name") +#info_line("Parent/Guardian 2 Relationship:", "parent_two_relationship") +#info_line("Parent/Guardian 2 Occupation:", "parent_two_occupation") +#info_line("Parent/Guardian 2 Employer:", "parent_two_employer") + += Extra Eligibility Information + +#info_line("Did the student attend BAS:", "attend_bas") +#info_line("Is the student a member of Midd-South Catholic Church:", "middsouth_church") +#info_line("Does the student have a family member who has served in the US Military:", "family_military_service") +#info_line("Has the student participated in Pomperaug Youth Baseball:", "youth_baseball") +#info_line("Has the student participated in the Panthers Aquatic Club:", "aquatic_club") +#info_line("Does the student have a family member in the Region 15 PEA:", "pea_member") +#info_line("Has the student participated in the PHS Music Program:", "music_program") diff --git a/src/pages/admin/utils_page.rs b/src/pages/admin/utils_page.rs index 202f770..a1e9966 100644 --- a/src/pages/admin/utils_page.rs +++ b/src/pages/admin/utils_page.rs @@ -7,6 +7,7 @@ use leptos::prelude::*; use leptos::task::spawn_local; use leptos::wasm_bindgen::JsCast; use leptos::web_sys::{Blob, HtmlAnchorElement, Url, js_sys}; +use crate::pages::api::students::{get_student_info_json, get_student_pdf}; fn get_important_dates() -> Vec { vec![ @@ -100,6 +101,36 @@ pub fn AdminUtilsPage() -> impl IntoView { }); }; + let on_click_json = move |_| { + spawn_local(async move { + let (file_name, file_bytes) = get_student_pdf("54d82438-c051-709d-30f7-c60e97f5112a".to_string()) + .await + .unwrap_or_default(); + + let uint_arr = js_sys::Uint8Array::new_with_length(file_bytes.len() as u32); + uint_arr.copy_from(&file_bytes); + + let blob_parts = js_sys::Array::new(); + blob_parts.push(&uint_arr); + + let blob = Blob::new_with_u8_array_sequence(&blob_parts).unwrap(); + + let blob_url = Url::create_object_url_with_blob(&blob).unwrap(); + + let window = window(); + let document = window.document().unwrap(); + let a: HtmlAnchorElement = document.create_element("a").unwrap().dyn_into().unwrap(); + + a.set_href(&blob_url); + a.set_download(&file_name); + document.body().unwrap().append_child(&a).unwrap(); + a.click(); + + document.body().unwrap().remove_child(&a).unwrap(); + Url::revoke_object_url(&blob_url).unwrap(); + }); + }; + view! {
@@ -109,6 +140,7 @@ pub fn AdminUtilsPage() -> impl IntoView { "Create Comparisons" "Create Dates" "Scholarship Export" + "Get student JSON"
}.into_any() diff --git a/src/pages/api/students.rs b/src/pages/api/students.rs index 1b1dc3d..c9089d6 100644 --- a/src/pages/api/students.rs +++ b/src/pages/api/students.rs @@ -2,6 +2,7 @@ use leptos::prelude::*; use leptos::server_fn::codec::Json; use std::collections::HashMap; use std::io::Write; +use std::process::Stdio; #[cfg(feature = "ssr")] mod imports { @@ -14,8 +15,12 @@ mod imports { pub use leptos::logging::{debug_log, error}; pub use std::collections::HashMap; pub use zip::write::SimpleFileOptions; + pub use leptos::serde_json; + pub use std::process::Command; } +static PDF_TEMPLATE: &str = include_str!("../../../pdf_template.typ"); + #[server(input = Json)] pub async fn put_student_data( subject: String, @@ -512,3 +517,152 @@ pub async fn provider_get_all_input_files( get_all_input_files(form_name, input_name).await } + +#[server] +pub async fn get_student_pdf( + student_id: String, +) -> Result<(String, Vec), ServerFnError> { + use imports::*; + + // NOTE: this API requires that the server has the typst-cli available ON PATH. + + let student_info_str = get_student_info_json(student_id.clone()).await?; + let student_info = get_student_data(student_id, "DEMOGRAPHICS".to_string()).await?; + + let first_name = student_info.get("first_name") + .map(|v| v.to_string()) + .unwrap_or_default(); + + let last_name = student_info.get("last_name") + .map(|v| v.to_string()) + .unwrap_or_default(); + + let typst_string = student_info_str + .replace("{", "(") + .replace("}", ")") + .replace("[", "(") + .replace("]", ")") + // Ensures trailing commas for lists of dictionaries + .replace("))", "),)"); + + debug_log!("New string: {}", typst_string); + + let pdf_string = format!("#let student = {typst_string}\n{PDF_TEMPLATE}"); + debug_log!("PDF string: {}", pdf_string); + let mut child = Command::new("typst") + .arg("compile") + .arg("-") + .arg("-") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .spawn()?; + + { + let stdin = child.stdin.as_mut().unwrap(); + stdin.write_all(pdf_string.as_bytes())?; + stdin.flush()?; + } + + let output = child.wait_with_output()?; + + if !output.status.success() { + let msg = String::from_utf8_lossy(&output.stderr).to_string(); + error!("Failed to generate PDF: {}", msg); + Err(ServerFnError::new(format!("Failed to generate PDF: {msg}"))) + } else { + let bytes = output.stdout; + let file_name = format!("{first_name}{last_name}_Application.pdf"); + Ok((file_name, bytes)) + } +} + +#[server] +pub async fn get_student_info_json( + student_id: String, +) -> Result { + use imports::*; + + let client = create_dynamo_client().await; + + let response = client + .query() + .table_name(MAIN_TABLE_NAME) + .expression_attribute_values(":hk", AttributeValue::S(format!("STUDENT#{student_id}"))) + .key_condition_expression("HK = :hk") + .send() + .await + .map_err(|e| { + let msg = e.message().unwrap_or("Unknown error occurred"); + error!("{}", msg); + ServerFnError::new(msg) + })?; + + let work_exp = client + .query() + .table_name(MAIN_TABLE_NAME) + .expression_attribute_values(":hk", AttributeValue::S(format!("STUDENT#{student_id}"))) + .expression_attribute_values(":sk", AttributeValue::S("WORKEXP".to_string())) + .key_condition_expression("HK = :hk AND SK = :sk") + .send() + .await + .map_err(|e| { + let msg = e.message().unwrap_or("Unknown error occurred"); + error!("{}", msg); + ServerFnError::new(msg) + }) + .map(|output| { + let items = output.items.unwrap_or_default(); + debug_log!("Number of items: {}", items.len()); + items.into_iter() + .next() + .unwrap_or_default() + .get("extracurricular") + .cloned() + .unwrap_or(AttributeValue::S("".to_string())) + })?; + + let extracurricular = client + .query() + .table_name(MAIN_TABLE_NAME) + .expression_attribute_values(":hk", AttributeValue::S(format!("STUDENT#{student_id}"))) + .expression_attribute_values(":sk", AttributeValue::S("DEMOGRAPHICS".to_string())) + .key_condition_expression("HK = :hk AND SK = :sk") + .send() + .await + .map_err(|e| { + let msg = e.message().unwrap_or("Unknown error occurred"); + error!("{}", msg); + ServerFnError::new(msg) + }) + .map(|output| { + let items = output.items.unwrap_or_default(); + debug_log!("Number of items: {}", items.len()); + items.into_iter() + .next() + .unwrap_or_default() + .get("extracurricular") + .cloned() + .unwrap_or(AttributeValue::S("".to_string())) + })?; + + let Some(items) = response.items else { + return Err(ServerFnError::new("Failed to find student information.")); + }; + + // Join items together into a single object. + let mut data = items.into_iter() + .fold(HashMap::new(), |mut map, item| { + item.into_iter() + .for_each(|(k, v)| { map.insert(k, v); }); + map + }); + + data.insert("work_experience".to_string(), work_exp); + data.insert("extracurricular".to_string(), extracurricular); + + let json_value: serde_json::Value = serde_dynamo::from_item(data)?; + let json_string = serde_json::to_string(&json_value)?; + println!("{}", json_string); + + Ok(json_string) +} diff --git a/src/pages/provider_applicants.rs b/src/pages/provider_applicants.rs index cc83dd6..1f1038b 100644 --- a/src/pages/provider_applicants.rs +++ b/src/pages/provider_applicants.rs @@ -2,7 +2,7 @@ use crate::components::{ActionButton, Banner, DataDisplay, Loading}; use crate::pages::UnauthenticatedPage; use crate::pages::api::get_comparison_info; -use crate::pages::api::students::{GetStudentFiles, provider_get_all_input_files, get_student_data}; +use crate::pages::api::students::{GetStudentFiles, provider_get_all_input_files, get_student_data, GetStudentPdf}; use base64::Engine; use leptos::ev::{Event, MouseEvent}; use leptos::html::Dialog; @@ -1120,7 +1120,10 @@ pub fn StudentInformationDialog( // scholarship, meaning their essays and/or FAFSA by request. let get_student_files = ServerAction::::new(); - let get_buttons_disabled = get_student_files.pending(); + let get_student_pdf = ServerAction::::new(); + let get_buttons_disabled = Memo::new(move |_| { + get_student_files.pending().get() && get_student_pdf.pending().get() + }); let on_click_fafsa = move |_| { get_student_files.dispatch(GetStudentFiles { access_token: access_token.get().unwrap_or_default(), @@ -1142,23 +1145,41 @@ pub fn StudentInformationDialog( }); }; + let on_click_pdf = move |_| { + debug_log!("Getting student PDF"); + get_student_pdf.dispatch(GetStudentPdf { + student_id: student_id.get().unwrap_or_default(), + }); + }; + + fn open_file(file_name: String, file: Vec) { + let base64 = base64::engine::general_purpose::STANDARD.encode(file); + let data_url = format!("data:application/zip;base64,{}", base64); + // Create a base64 link and open it. + let a: HtmlAnchorElement = document() + .create_element("a") + .expect("Failed to create anchor element") + .dyn_into() + .unwrap(); + + a.set_download(&*file_name); + a.set_href(&data_url); + document().body().unwrap().append_child(&a).unwrap(); + a.click(); + a.remove(); + } + Effect::new(move || { if let Some(Ok((file_name, file))) = get_student_files.value().get() { - let base64 = base64::engine::general_purpose::STANDARD.encode(file); - let data_url = format!("data:application/zip;base64,{}", base64); - // Create a base64 link and open it. - let a: HtmlAnchorElement = document() - .create_element("a") - .expect("Failed to create anchor element") - .dyn_into() - .unwrap(); - - a.set_download(&*file_name); - a.set_href(&data_url); - document().body().unwrap().append_child(&a).unwrap(); - a.click(); - a.remove(); + open_file(file_name, file); + get_student_files.clear(); + } + }); + // Copy and paste is bad, but it's a little short on time. + Effect::new(move || { + if let Some(Ok((file_name, file))) = get_student_pdf.value().get() { + open_file(file_name, file); get_student_files.clear(); } }); @@ -1192,6 +1213,9 @@ pub fn StudentInformationDialog( "Download Essay File(s)" + + "Download Student Application" + {move || Suspend::new(async move { get_student_data(