feat: implement CSV export of scholarships
This commit is contained in:
1 parent
be54aa8fd2
commit
c98621aefa
6 files changed
+307
-120
No files matched your search
Generated
+22
@@ -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",
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -74,6 +74,7 @@ pub fn AppWithRoutes() -> impl IntoView {
|
||||
<ProviderRoutes />
|
||||
<LoanerRoutes />
|
||||
<StudentRoutes />
|
||||
<AdminRoutes />
|
||||
</Routes>
|
||||
</ToastList>
|
||||
</main>
|
||||
|
||||
+39
-114
@@ -1,8 +1,12 @@
|
||||
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<DateInfo> {
|
||||
vec![
|
||||
@@ -11,7 +15,7 @@ fn get_important_dates() -> Vec<DateInfo> {
|
||||
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<DateInfo> {
|
||||
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<DateInfo> {
|
||||
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<DateInfo> {
|
||||
]
|
||||
}
|
||||
|
||||
#[server]
|
||||
async fn create_scholarship_export() -> Result<Vec<u8>, 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<String>,
|
||||
}
|
||||
|
||||
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::<Vec<HashMap<String, ValueType>>>()
|
||||
);
|
||||
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::<Vec<HashMap<String, ValueType>>>()
|
||||
);
|
||||
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<HashMap<String, ValueType::List(Option<Vec<ValueType::String()>>)>>)
|
||||
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::<Vec<ValueType>>() // This is a Vec<ValueType::String>
|
||||
.iter().map(|val| val.clone().as_string().ok().flatten().unwrap_or_default())
|
||||
.collect::<Vec<String>>();
|
||||
|
||||
// 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::<Vec<String>>();
|
||||
|
||||
ScholarshipInfo {
|
||||
name,
|
||||
provider_name: provider_id,
|
||||
relations: parsed_relations,
|
||||
}
|
||||
})
|
||||
.collect::<Vec<ScholarshipInfo>>();
|
||||
|
||||
// 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::<CreateTestComparisons>::new();
|
||||
let create_dates = ServerAction::<CreateDates>::new();
|
||||
let create_export = ServerAction::<CreateScholarshipExport>::new();
|
||||
let create_export = ServerAction::<GetScholarshipCsv>::new();
|
||||
|
||||
let on_click_comparisons = move |_| {
|
||||
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! {
|
||||
|
||||
@@ -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<Vec<u8>, 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::<HashMap<String, ValueType>>()
|
||||
})
|
||||
.collect::<Vec<ExpandableInfo>>()
|
||||
};
|
||||
|
||||
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::<HashMap<String, ValueType>>()
|
||||
})
|
||||
.collect::<Vec<HashMap<String, ValueType>>>()
|
||||
};
|
||||
|
||||
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::<Vec<String>>();
|
||||
|
||||
(category.clone(), resolved_names)
|
||||
})
|
||||
.collect::<IndexMap<String, Vec<String>>>();
|
||||
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::<Vec<ResolvedScholarshipInfo>>();
|
||||
|
||||
// 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)
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
Reference in new issue
Block a user