diff --git a/Cargo.lock b/Cargo.lock index 5290b50..f753494 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -829,6 +829,27 @@ dependencies = [ "typenum", ] +[[package]] +name = "csv" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52cd9d68cf7efc6ddfaaee42e7288d3a99d613d4b50f76ce9827ae0c6e14f938" +dependencies = [ + "csv-core", + "itoa", + "ryu", + "serde_core", +] + +[[package]] +name = "csv-core" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704a3c26996a80471189265814dbc2c257598b96b8a7feae2d31ace646bb9782" +dependencies = [ + "memchr", +] + [[package]] name = "darling" version = "0.20.11" @@ -3027,6 +3048,7 @@ dependencies = [ "base64 0.22.1", "chrono", "console_error_panic_hook", + "csv", "icondata", "indexmap", "leptos", diff --git a/Cargo.toml b/Cargo.toml index c29aaf5..401029d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -30,6 +30,7 @@ traits = { path = "./traits" } icondata = { version = "0.7.0", features = ["font-awesome"] } leptos_icons = "0.7.1" indexmap = { version = "2.13.0", features = ["serde"] } +csv = "1.4.0" [features] default = ["ssr"] diff --git a/src/app.rs b/src/app.rs index 7170ee1..75517b3 100644 --- a/src/app.rs +++ b/src/app.rs @@ -74,6 +74,7 @@ pub fn AppWithRoutes() -> impl IntoView { + diff --git a/src/pages/admin/utils_page.rs b/src/pages/admin/utils_page.rs index 7b8e7d1..202f770 100644 --- a/src/pages/admin/utils_page.rs +++ b/src/pages/admin/utils_page.rs @@ -1,17 +1,21 @@ -use chrono::DateTime; +use crate::common::{DateInfo, DateRange}; use crate::components::ActionButton; +use crate::pages::api::exports::get_scholarship_csv; +use crate::pages::api::{CreateDates, CreateTestComparisons, exports::GetScholarshipCsv}; +use chrono::DateTime; use leptos::prelude::*; -use crate::common::{DateInfo, DateRange}; -use crate::pages::api::{CreateDates, CreateTestComparisons}; +use leptos::task::spawn_local; +use leptos::wasm_bindgen::JsCast; +use leptos::web_sys::{Blob, HtmlAnchorElement, Url, js_sys}; fn get_important_dates() -> Vec { - vec! [ + vec![ DateInfo { id: uuid::Uuid::new_v4().to_string(), title: "Provider Forms Open".to_string(), date: DateRange::Range( DateTime::parse_from_rfc3339("2026-01-20T07:15:00-05:00").unwrap(), - DateTime::parse_from_rfc3339("2026-03-03T23:59:00-05:00").unwrap() + DateTime::parse_from_rfc3339("2026-03-03T23:59:00-05:00").unwrap(), ), description: "".to_string(), }, @@ -20,7 +24,7 @@ fn get_important_dates() -> Vec { title: "Student Forms Open".to_string(), date: DateRange::Range( DateTime::parse_from_rfc3339("2026-03-03T07:15:00-05:00").unwrap(), - DateTime::parse_from_rfc3339("2026-03-27T14:05:00-05:00").unwrap() + DateTime::parse_from_rfc3339("2026-03-27T14:05:00-05:00").unwrap(), ), description: "".to_string(), }, @@ -29,7 +33,7 @@ fn get_important_dates() -> Vec { title: "Scholarship Decision Window".to_string(), date: DateRange::Range( DateTime::parse_from_rfc3339("2026-04-10T07:15:00-05:00").unwrap(), - DateTime::parse_from_rfc3339("2026-05-01T23:59:00-05:00").unwrap() + DateTime::parse_from_rfc3339("2026-05-01T23:59:00-05:00").unwrap(), ), description: "Start date may change if available sooner.".to_string(), }, @@ -52,127 +56,48 @@ fn get_important_dates() -> Vec { ] } -#[server] -async fn create_scholarship_export() -> Result, ServerFnError> { - // Exports olarship information - specifically the name of the scholarship, the name of the - // provider, and the scholarship's requirements. - // This means we'll need to get all the relations, all the scholarships, and all the contact info. - use crate::utils::server::create_dynamo_client; - use aws_sdk_dynamodb::client::Client; - use crate::pages::api::{get_comparison_info, SCHOLARSHIPS_TABLE, PROVIDER_CONTACT_TABLE}; - use std::collections::HashMap; - use crate::common::ValueType; - - let scan_table_items = async |client: &Client, table_name: &str| { - client.scan() - .table_name(table_name) - .send() - .await - .ok().and_then(|res| res.items) - .unwrap_or_default() - }; - - #[derive(serde::Serialize, serde::Deserialize, Debug)] - struct ScholarshipInfo { - name: String, - provider_name: String, - relations: Vec, - } - - let client = create_dynamo_client().await; - - let scholarships = dbg!(scan_table_items(&client, SCHOLARSHIPS_TABLE) - .await - .iter().map(|item| { - item.into_iter() - .map(|(k, v)| (k.clone(), ValueType::from(v))) - .collect() - }).collect::>>() - ); - let contacts = dbg!( - scan_table_items(&client, PROVIDER_CONTACT_TABLE).await - .iter().map(|item| { - item.into_iter() - .map(|(k, v)| (k.clone(), ValueType::from(v))) - .collect() - }).collect::>>() - ); - let db_relations = dbg!( - get_comparison_info().await.unwrap_or_default() - ); - - // Build a new structure containing some of the scholarship's information. - let resolved = scholarships.into_iter() - .map(|scholarship| { - // We just want the scholarship's name, the relations, and the provider's name. - // In here, we're going to resolve all the scholarship's information to provide this in - // a readable format. - let name = scholarship.get("name") - .unwrap_or(&ValueType::String(None)) - .to_string(); - - // The list of relations - we're going to search the master list for each comparison's - // name. It's structured as a map of lists, or a ValueType::Map(Option>)>>) - let relations = scholarship.get("requirements") - .unwrap_or(&ValueType::Map(None)) - .as_map().ok().flatten() - .unwrap_or_default() // We have our map of categories, now we need all the values inside each category. - .iter() - .map(|(_, list)| { - list.as_list().ok().flatten() - .unwrap_or_default() - }) - .flatten() - .collect::>() // This is a Vec - .iter().map(|val| val.clone().as_string().ok().flatten().unwrap_or_default()) - .collect::>(); - - // The provider ID, which we'll use to search for the provider's name. - let provider_id = scholarship.get("provider_id") - .unwrap_or(&ValueType::String(None)) - .to_string(); - - // This is a mess, we'll clean it up later. - let parsed_relations = relations - .iter() - .filter_map(|relation| { - db_relations.iter().find(|search| { - search.id == *relation - }) - .and_then(|search| Some(search.display_text.clone())) - }) - .collect::>(); - - ScholarshipInfo { - name, - provider_name: provider_id, - relations: parsed_relations, - } - }) - .collect::>(); - - // TODO Convert into CSV and send to client. For now I think I'm just going to serialize to json and print it. - leptos::logging::log!("Scholarships list: {:?}", resolved); - - Ok(Vec::new()) -} - #[component] pub fn AdminUtilsPage() -> impl IntoView { let create_comparisons = ServerAction::::new(); let create_dates = ServerAction::::new(); - let create_export = ServerAction::::new(); + let create_export = ServerAction::::new(); let on_click_comparisons = move |_| { - create_comparisons.dispatch(CreateTestComparisons{}); + create_comparisons.dispatch(CreateTestComparisons {}); }; let on_click_dates = move |_| { - create_dates.dispatch(CreateDates{dates: get_important_dates()}); + create_dates.dispatch(CreateDates { + dates: get_important_dates(), + }); }; let on_click_export = move |_| { - create_export.dispatch(CreateScholarshipExport{}); + spawn_local(async move { + let file_bytes = get_scholarship_csv().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("scholarships.csv"); + document.body().unwrap().append_child(&a).unwrap(); + a.click(); + + document.body().unwrap().remove_child(&a).unwrap(); + Url::revoke_object_url(&blob_url).unwrap(); + }); }; view! { diff --git a/src/pages/api/exports.rs b/src/pages/api/exports.rs new file mode 100644 index 0000000..54a39f3 --- /dev/null +++ b/src/pages/api/exports.rs @@ -0,0 +1,237 @@ +#[cfg(feature = "ssr")] +mod imports { + pub use super::super::{COMPARISONS_TABLE, PROVIDER_CONTACT_TABLE, SCHOLARSHIPS_TABLE}; + pub use crate::common::ValueType; + pub use crate::utils::server::create_dynamo_client; + pub use aws_sdk_dynamodb::Client; + pub use aws_sdk_dynamodb::error::ProvideErrorMetadata; + pub use indexmap::IndexMap; + pub use std::collections::HashMap; +} + +use crate::common::ExpandableInfo; +use leptos::prelude::*; + +#[server] +pub async fn get_scholarship_csv() -> Result, ServerFnError> { + use imports::*; + use leptos::logging::debug_log; + + #[derive(serde::Serialize)] + struct ResolvedScholarshipInfo { + contact_email: String, + first_name: String, + last_name: String, + scholarship_name: String, + sports_requirements: String, + gpa_requirements: String, + major_requirements: String, + additional_requirements: String, + community_requirements: String, + residency_requirements: String, + } + + // Get the scholarships and comparisons + let client = create_dynamo_client().await; + + let scan_table = async |client: &Client, table_name| { + client + .scan() + .table_name(table_name) + .send() + .await + .map(|output| output.items) + .ok() + .flatten() + .unwrap_or_default() + .into_iter() + .map(|item| { + serde_dynamo::from_item(item).unwrap_or(ExpandableInfo::new("".to_string())) + // item.iter() + // .map(|(k, v)| (k.to_owned(), ValueType::from(v))) + // .collect::>() + }) + .collect::>() + }; + + let scan_table_hash_map = async |client: &Client, table_name| { + client + .scan() + .table_name(table_name) + .send() + .await + .map(|output| output.items) + .ok() + .flatten() + .unwrap_or_default() + .into_iter() + .map(|item| { + // serde_dynamo::from_item(item).unwrap_or(ExpandableInfo::new("".to_string())) + item.iter() + .map(|(k, v)| (k.to_owned(), ValueType::from(v))) + .collect::>() + }) + .collect::>>() + }; + + let master_scholarships = scan_table(&client, SCHOLARSHIPS_TABLE).await; + let master_contacts = scan_table_hash_map(&client, PROVIDER_CONTACT_TABLE).await; + let master_relations = scan_table_hash_map(&client, COMPARISONS_TABLE).await; + + // We now want to map the relations to the scholarships, as well as the contacts. + // This should take the information from the current scholarship, then relate the IDs of the + // requirements in the scholarship to the IDs of the relations in the master list. + let resolved_scholarships = master_scholarships + .into_iter() + .map(|scholarship| { + // debug_log!("Scholarship: {:?}", scholarship); + // We want the requirements IDs and the provider ID. + let requirements_map = scholarship + .data + .get("requirements") + .unwrap_or(&ValueType::Map(None)) + .as_map() + .ok() + .flatten() + .unwrap_or_default(); + + let provider_id = scholarship + .data + .get("provider_id") + .map(|v| v.as_string().ok().flatten().unwrap_or_default()) + .unwrap_or_default(); + + // Search for the provider ID in the master list. + let contact_info = master_contacts + .iter() + .find(|contact| { + let subject = contact + .get("subject") + .map(|v| v.as_string().ok().flatten().unwrap_or_default()) + .unwrap_or_default(); + + debug_log!("Comparing {:?} and {:?}", subject, provider_id); + + subject == provider_id + }) + .cloned() + .unwrap_or_default(); + + // Resolve the requirements - this should resolve as another category-separated map, + // but with requirement display text instead of IDs. + let resolved_requirements = requirements_map + .iter() + .map(|(category, selected_val)| { + let selected_list = selected_val.as_list().ok().flatten().unwrap_or_default(); + + let resolved_names = selected_list + .into_iter() + .map(|id_val| { + let id = id_val.as_string().ok().flatten().unwrap_or_default(); + + let resolved_relation = master_relations + .iter() + .find(|item| { + let comp_id = item + .get("id") + .map(|v| v.as_string().ok().flatten().unwrap_or_default()) + .unwrap_or_default(); + + comp_id == id + }) + .cloned() + .map(|v| { + v.get("display_text").map(|text_opt| { + text_opt.as_string().ok().flatten().unwrap_or_default() + }) + }) + .flatten() + .unwrap_or_default(); + + resolved_relation + }) + .collect::>(); + + (category.clone(), resolved_names) + }) + .collect::>>(); + debug_log!("Resolved requirements: {:?}", resolved_requirements); + + let contact_email = contact_info + .get("contact_email") + .map(|v| v.as_string().ok().flatten().unwrap_or_default()) + .unwrap_or_default(); + + let first_name = contact_info + .get("first_name") + .map(|v| v.as_string().ok().flatten().unwrap_or_default()) + .unwrap_or_default(); + + let last_name = contact_info + .get("last_name") + .map(|v| v.as_string().ok().flatten().unwrap_or_default()) + .unwrap_or_default(); + + let scholarship_name = scholarship + .data + .get("name") + .map(|v| v.as_string().ok().flatten().unwrap_or_default()) + .unwrap_or_default(); + + debug_log!( + "{:?}, {:?}, {:?}, {:?}", + scholarship_name, + contact_email, + first_name, + last_name + ); + + // We want to create some structure that's got the correct information. Specifically, + // we only need the provider contact info, the scholarship's name, and the requirements + // that they chose in plain text. + ResolvedScholarshipInfo { + first_name, + last_name, + contact_email, + scholarship_name, + sports_requirements: resolved_requirements + .get("sports_participation") + .map(|list| list.join("; ")) + .unwrap_or_default(), + gpa_requirements: resolved_requirements + .get("gpa") + .map(|list| list.join("; ")) + .unwrap_or_default(), + major_requirements: resolved_requirements + .get("major") + .map(|list| list.join("; ")) + .unwrap_or_default(), + additional_requirements: resolved_requirements + .get("misc") + .map(|list| list.join("; ")) + .unwrap_or_default(), + community_requirements: resolved_requirements + .get("community_service") + .map(|list| list.join("; ")) + .unwrap_or_default(), + residency_requirements: resolved_requirements + .get("residency") + .map(|list| list.join("; ")) + .unwrap_or_default(), + } + }) + .collect::>(); + + // Now that we have a series of scholarships, we need to serialize them into CSV and send the + // file to the client. + let mut writer = csv::Writer::from_writer(Vec::new()); + resolved_scholarships.iter().for_each(|scholarship| { + _ = writer.serialize(scholarship); + }); + writer.flush()?; + + let result = writer.into_inner()?; + debug_log!("{:?}", String::from_utf8(result.clone())); + + Ok(result) +} diff --git a/src/pages/api/mod.rs b/src/pages/api/mod.rs index fb8c5e8..ae7606f 100644 --- a/src/pages/api/mod.rs +++ b/src/pages/api/mod.rs @@ -1,15 +1,16 @@ -mod comparisons; -mod scholarships; -mod dates; mod admin; +mod comparisons; +mod dates; +pub mod exports; mod providers; +mod scholarships; pub mod students; -pub use comparisons::*; -pub use scholarships::*; -pub use dates::*; pub use admin::*; +pub use comparisons::*; +pub use dates::*; pub use providers::*; +pub use scholarships::*; #[cfg(feature = "ssr")] pub static COMPARISONS_TABLE: &str = "leptos-comparisons";