diff --git a/Cargo.toml b/Cargo.toml index 2492772..db5a983 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,7 +25,6 @@ leptos_oidc = "0.9.0" base64 = "0.22" chrono = "0.4" leptos_animate = "0.1" - macros = { path = "./macros" } traits = { path = "./traits" } icondata = { version = "0.7.0", features = ["font-awesome"] } diff --git a/src/app.rs b/src/app.rs index 07bb0e0..bfd5c97 100644 --- a/src/app.rs +++ b/src/app.rs @@ -93,11 +93,11 @@ fn ProviderRoutes() -> impl MatchNestedRoutes + Clone { - - // - // - // - // + // + + + + } .into_inner() @@ -158,6 +158,7 @@ fn StudentRoutes() -> impl MatchNestedRoutes + Clone { path=path!("additional/specifics") view=student::StudentSpecificRequirementsPage /> + } .into_inner() diff --git a/src/components/data_display.rs b/src/components/data_display.rs index 3ab3173..e9b7d23 100644 --- a/src/components/data_display.rs +++ b/src/components/data_display.rs @@ -10,7 +10,9 @@ use std::collections::HashMap; #[component] pub fn ValueDisplay( #[prop(into)] schema: Signal, - #[prop(into, optional)] data_member: Signal, + /// The starting member. Starts display from this member in the `data_map`, if it exists. + #[prop(into, optional)] + data_member: Signal, #[prop(into)] data_map: Signal>, ) -> impl IntoView { let data_value = Memo::new(move |_| { diff --git a/src/pages/api/students.rs b/src/pages/api/students.rs index c18f657..9244d4f 100644 --- a/src/pages/api/students.rs +++ b/src/pages/api/students.rs @@ -1,10 +1,12 @@ use leptos::prelude::*; use leptos::server_fn::codec::Json; +use std::collections::HashMap; #[cfg(feature = "ssr")] mod imports { pub use super::super::MAIN_TABLE_NAME; pub use crate::common::ValueType; + pub use crate::pages::api::tokens::validate_and_get_token_info; pub use crate::utils::server::*; pub use aws_sdk_dynamodb::error::ProvideErrorMetadata; pub use aws_sdk_dynamodb::types::AttributeValue; @@ -16,7 +18,7 @@ mod imports { pub async fn put_student_data( subject: String, data_type: String, - data_map: std::collections::HashMap, + data_map: HashMap, ) -> Result<(), ServerFnError> { use imports::*; @@ -52,7 +54,7 @@ pub async fn put_student_data( pub async fn get_student_data( subject: String, data_type: String, -) -> Result, ServerFnError> { +) -> Result, ServerFnError> { use imports::*; let client = create_dynamo_client().await; @@ -99,7 +101,7 @@ pub async fn get_student_data( #[server] pub async fn get_all_student_data( subject: String, -) -> Result, ServerFnError> { +) -> Result, ServerFnError> { use imports::*; let client = create_dynamo_client().await; @@ -131,3 +133,80 @@ pub async fn get_all_student_data( ServerFnError::new(msg) }) } + +#[server] +pub async fn get_completed_students( + access_token: String, +) -> Result>, ServerFnError> { + use imports::*; + + // We want to get all student information. The requirements are that the students have completed + // the demographics form - everything else may bed from this. + // The easiest way is to get all the information and filter on this side, instead of bookkeeping + // on the database's side. + + // We want to verify the access token first, and make sure that the user has the correct access. + let claims = validate_and_get_token_info(access_token).await?; + if !claims.groups.contains(&"ScholarshipProviders".to_string()) { + return Err(ServerFnError::new( + "User is not in the ScholarshipProviders group", + )); + } + + let client = create_dynamo_client().await; + + let response = client + .scan() + .table_name(MAIN_TABLE_NAME) + .send() + .await + .map_err(|err| { + let msg = err.message().unwrap_or("Unknown error occurred"); + error!("{}", msg); + ServerFnError::new(msg) + })?; + + let Some(items) = response.items else { + return Ok(HashMap::new()); + }; + + let mut output = HashMap::>::new(); + + items.into_iter().for_each(|mut form_info| { + let student_id_full = form_info + .get("HK") + .map(|v| v.as_s().cloned().unwrap_or_default()) + .unwrap_or_default() + .to_owned(); + + let student_id = student_id_full.split("STUDENT#").collect::(); + + form_info.remove("HK"); + form_info.remove("SK"); + + let form_info_convert = form_info + .into_iter() + .map(|(k, v)| (k, ValueType::from(&v))) + .collect::>(); + + // We want to insert all the remaining information into the output map + output + .entry(student_id) + .and_modify(|v| { + v.extend(form_info_convert.clone()); + }) + .or_insert(form_info_convert); + }); + + // Don't love this, but it does verify that the student has completed the demographics form + let output = output + .into_iter() + .filter_map(|(k, student_info)| { + student_info + .get("first_name") + .and_then(|_| Some((k, student_info.clone()))) + }) + .collect(); + + Ok(output) +} diff --git a/src/pages/provider_applicants.rs b/src/pages/provider_applicants.rs index c1ae3f3..2eb78c1 100644 --- a/src/pages/provider_applicants.rs +++ b/src/pages/provider_applicants.rs @@ -1,9 +1,16 @@ -use crate::common::{ExpandableInfo, ValueType}; -use crate::components::{Banner, Loading}; +use crate::common::{ComparisonData, ExpandableInfo, SchemaNode, SchemaType, ValueType}; +use crate::components::{Banner, Loading, ValueDisplay}; +use crate::pages::UnauthenticatedPage; +use crate::pages::api::get_comparison_info; +use crate::pages::api::students::{get_all_student_data, get_student_data}; +use leptos::ev::{Event, MouseEvent}; +use leptos::html::Dialog; +use leptos::logging::debug_log; use leptos::prelude::*; -use leptos_oidc::AuthSignal; +use leptos_oidc::{AuthLoaded, AuthSignal, Authenticated}; use leptos_router::components::Outlet; -use leptos_router::hooks::use_navigate; +use leptos_router::hooks::{query_signal, use_navigate}; +use std::collections::HashMap; #[component] pub fn ApplicantsPageFallback() -> impl IntoView { @@ -28,17 +35,25 @@ pub fn ApplicantsPageShell() -> impl IntoView { // each of which is still considered a single page. view! { - -
-
-
- -
- + + + +
+
+
+ +
+ +
+
+
-
-
-
+ + } } @@ -98,7 +113,8 @@ fn ApplicantsScholarshipList() -> impl IntoView { // It avoids having to define it within the Transition component. It's wrapped in a Callback // because this makes it Send + Sync. let on_view_click = Callback::new(move |scholarship_id: String| { - navigate(&*scholarship_id, Default::default()); + let path = format!("/providers/applicants/{}", scholarship_id); + navigate(&path, Default::default()); }); view! { @@ -175,4 +191,389 @@ fn ApplicantsScholarshipEntry( } #[component] -fn ApplicantsStudentList() -> impl IntoView {} +pub fn ApplicantsStudentList() -> impl IntoView { + use crate::pages::api::get_scholarship_info; + use crate::pages::api::students::get_completed_students; + use leptos_router::hooks::use_params; + use leptos_router::params::Params; + + /// The parameters for the page that displays this component. This may be moved to the page-level + /// rather than the component-level. + #[derive(Params, PartialEq)] + struct StudentListParams { + scholarship_id: Option, + } + + // This is in charge of finding all student information and running the comparisons. + // It will then display all the eligible students. + + let params = use_params::(); + let auth = expect_context::(); + let access_token = + Memo::new(move |_| auth.with(|a| a.authenticated().map(|a| a.access_token()))); + + let scholarship_id = Memo::new(move |_| { + params + .read() + .as_ref() + .ok() + .and_then(|params| params.scholarship_id.clone()) + }); + + #[server] + async fn get_eligibility_info( + token: String, + scholarship_id: String, + ) -> Result< + ( + HashMap>, + ExpandableInfo, + Vec, + ), + ServerFnError, + > { + let (students, scholarships, requirements_list) = tokio::join!( + get_completed_students(token), + get_scholarship_info(scholarship_id), + get_comparison_info() + ); + + Ok((students?, scholarships?, requirements_list?)) + } + + let resource = Resource::new( + move || (access_token.get(), scholarship_id.get()), + move |(access_token, scholarship_id)| async move { + let Some(token) = access_token else { + return Err(ServerFnError::new("Couldn't find access token")); + }; + let Some(scholarship_id) = scholarship_id else { + return Err(ServerFnError::new("Couldn't find scholarship ID")); + }; + + get_eligibility_info(token, scholarship_id).await + }, + ); + + view! { +
+ + {move || { + resource + .get() + .map(|result| { + match result { + Ok((students, scholarship, requirements_list)) => { + view! { +
"Showing students here."
+ + } + .into_any() + } + Err(e) => { + view! { +
+ "Error while getting eligible students: "{e.to_string()} +
+ } + .into_any() + } + } + }) + }} +
+
+ } +} + +#[component] +fn ApplicantsStudentListView( + #[prop(into)] students: Signal>>, + #[prop(into)] scholarship: Signal, + #[prop(into)] requirements: Signal>, +) -> impl IntoView { + let eligible_students = RwSignal::new(HashMap::new()); + let error_msg = RwSignal::new(String::new()); + // let (current_student_id, set_current_student_id) = query_signal::("student_id"); + let current_student_id = RwSignal::new(None); + let navigate_student = Callback::new(move |id: String| { + current_student_id.set(Some(id)); + }); + let dialog_visible = + Signal::derive(move || !current_student_id.get().unwrap_or_default().is_empty()); + let on_dialog_close = Callback::new(move |_| { + current_student_id.set(None); + }); + + fn get_eligible_students( + students: HashMap>, + scholarship: ExpandableInfo, + requirements: Vec, + ) -> Result>, Error> { + let requires_essay = !scholarship + .data + .get("essay_prompt") + .unwrap_or(&ValueType::String(None)) + .as_string() + .map_err(|_| "Couldn't get essay as a string")? + .unwrap_or_default() + .is_empty(); + + // Get the full map of requirements. Each key contains a list of IDs. + let scholarship_requirements = scholarship + .data + .get("requirements") + .map(|val| val.as_map().ok()?) + .flatten() + .unwrap_or_default(); + + // Get the lists of requirements. Maps into string IDs and discards invalid ValueTypes. + let requirement_lists = scholarship_requirements + .into_iter() + .filter_map(|(_, list_val)| { + let list = list_val.as_list().ok().flatten()?; + let string_list = list + .into_iter() + .filter_map(|v| v.as_string().ok().flatten()) + .collect::>(); + + Some(string_list) + }) + .collect::>>(); + + // Resolve the requirement IDs. Discards all IDs that do not resolve. + let resolved_requirements = requirement_lists + .into_iter() + .map(|list| { + list.into_iter() + .filter_map(|id| { + requirements + .iter() + .cloned() + .find(|requirement| requirement.id == id) + }) + .collect::>() + }) + .collect::>>(); + + // Check student eligibility. These are all students that have completed the demographics + // forms. + let eligible_students = students + .into_iter() + .filter_map(|(id, student)| { + // The student needs to pass one requirement from each list. + let result = resolved_requirements.iter().all(|list| { + if list.is_empty() { + return true; + } + + list.iter() + .any(|requirement| requirement.compare(&student).unwrap_or(false)) + }); + + if result { Some((id, student)) } else { None } + }) + .collect::>>(); + + // debug_log!("Total number of eligible students: {}", eligible_students.len()); + + Ok(eligible_students) + } + + Effect::new(move || { + debug_log!("Running eligibility check on students"); + let eligible_res = + get_eligible_students(students.get(), scholarship.get(), requirements.get()); + + match eligible_res { + Ok(list) => { + debug_log!("Total number of eligible students: {}", list.len()); + eligible_students.set(list); + } + Err(e) => error_msg.set(format!("Failed to get eligible students list: {e}")), + } + }); + + // This is actually just a display of all the information from the students list. + // As defined in the ApplicantsStudentList component, this will already be wrapped in a + // flex container. + view! { + + +
"Error checking student eligibility: "{error_msg}
+
+
+ + {first_name} + " " + {last_name} +
+ } + } + /> +
+ } +} + +#[component] +fn StudentInformationDialog( + #[prop(into)] student_id: Signal>, + // #[prop(into)] scholarship_info: StoredValue, + #[prop(into)] visible: Signal, + #[prop(into)] on_close: Callback<()>, +) -> impl IntoView { + // This component needs to just display all student information. + // Specifically, it needs to know what information to display, like whether the scholarship + // requested an essay or financial information. + + let dialog_ref = NodeRef::::new(); + + let demographics_schema = StoredValue::new( + SchemaNode::new(SchemaType::Map) + .header("Demographics Information") + .child( + "first_name", + SchemaNode::new(SchemaType::String).header("First Name"), + ) + .child( + "last_name", + SchemaNode::new(SchemaType::String).header("Last Name"), + ) + .child( + "dob", + SchemaNode::new(SchemaType::String).header("Date of Birth"), + ) + .child( + "gender", + SchemaNode::new(SchemaType::String).header("Gender"), + ) + .child( + "phone_number", + SchemaNode::new(SchemaType::String).header("Preferred Phone Number"), + ) + .child( + "email", + SchemaNode::new(SchemaType::String).header("Preferred Email Address"), + ) + .child( + "street_address", + SchemaNode::new(SchemaType::String).header("Street Address"), + ) + .child("town", SchemaNode::new(SchemaType::String).header("Town")), + ); + + let sports_schema = StoredValue::new( + SchemaNode::new(SchemaType::List) + .header("Sports Activities") + .item_template( + SchemaNode::new(SchemaType::Map) + .header("Sport") + .child( + "sport_name", + SchemaNode::new(SchemaType::String).header("Sport Name"), + ) + .child( + "achievements", + SchemaNode::new(SchemaType::String).header("Special Achievements"), + ), + ), + ); + + let extracurricular_schema = + StoredValue::new(SchemaNode::new(SchemaType::Map).header("Extracurricular Activities")); + + Effect::new(move || { + if let Some(dialog) = dialog_ref.get() { + let open = visible.get(); + if open { + let _ = dialog.show_modal(); + debug_log!("Modal should be visible."); + } else { + dialog.close(); + debug_log!("Closed modal."); + } + } + }); + + view! { + +
+ + {move || Suspend::new(async move { + get_student_data( + student_id.get().unwrap_or_default(), + "demographics".to_string(), + ) + .await + .map(|student_info| { + view! { + + } + }) + })} + {move || Suspend::new(async move { + get_student_data( + student_id.get().unwrap_or_default(), + "athletics".to_string(), + ) + .await + .map(|student_info| { + view! { + + } + }) + })} + +
+
+ } +}