Compare commits

...
12 Commits
Author SHA1 Message Date
alphafetus 9ff4b892f5 chore: update README 2026-10-25 14:10:56 -04:00
Lucas Vas 2e0e47d72b Merge pull request #81 from R15-Software-Dev/fix/admin-file-errors
Insert trailing commas after lists of strings
2026-04-27 15:02:16 -04:00
alphafetus 287598bb79 fix: insert trailing commas after lists of strings
This was already present for lists of dictionaries, but lists that contained a single string also failed to parse properly.
2026-04-27 14:57:31 -04:00
Lucas Vas fff1854067 Merge pull request #80 from R15-Software-Dev/fix/admin-file-errors
Allow essays and fafsa to be requested by administrators
2026-04-27 14:24:20 -04:00
alphafetus 6d6862a680 fix: allow essays and fafsa to be requested by administrators 2026-04-27 14:08:14 -04:00
Lucas Vas 61b0c8b83b Merge pull request #79 from R15-Software-Dev/feat/admin-applicants
Add new admin APIs and applicants page, new PDF downloads
2026-04-27 03:30:22 -04:00
alphafetus e9a42aa0fa fix: update dockerfile to build correctly 2026-04-27 03:29:58 -04:00
alphafetus 5b44bfb914 chore: style admin applicants list layout 2026-04-27 02:53:17 -04:00
alphafetus 8282ba4302 feat: add pdf gen capability 2026-04-27 02:52:44 -04:00
alphafetus 3cc67cb9e6 feat: add new admin APIs and applicants page 2026-04-25 18:06:38 -04:00
Lucas Vas 26b984904b Merge pull request #78 from R15-Software-Dev/feat/api-file-name
Name student files to be more recognizable
2026-04-21 09:30:37 -04:00
alphafetus 4f1d1c5b1c feat: name student files to be more recognizable 2026-04-21 08:19:43 -04:00
14 changed files with 1119 additions and 60 deletions

No files matched your search

+6 -2
View File
@@ -27,14 +27,18 @@ RUN cargo binstall fnm -y \
# Start second phase of build - install ca-certs and copy all build outputs from previous step into a
# Debian image in the /app directory.
FROM debian:trixie-slim AS runtime
FROM rust:1.95-slim-trixie AS runtime
WORKDIR /app
RUN apt-get update -y \
&& apt-get install -y --no-install-recommends openssl ca-certificates \
&& apt-get install -y --no-install-recommends openssl ca-certificates curl \
&& apt-get autoremove -y \
&& apt-get clean -y \
&& rm -rf /var/lib/apt/lists/*
# Cargo, then binstall, then typst installation.
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/
+2 -6
View File
@@ -13,9 +13,5 @@ from the AWS resources, you will need to be approved by me or someone else with
usable auth key from AWS IAM.
## Current Tasks
There are several tasks left to complete. These are a few of the larger ones:
- Build the application's custom elements
- Build out the database requests
- Create proper authentication
- PDF generation using data (server-side)
The application is deployed! However, it doesn't have some core features that need to be present in order for this
to be considered complete. Check the Issues tab for more information.
+141
View File
@@ -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")
+5 -6
View File
@@ -1,11 +1,6 @@
use crate::components::ToastList;
use crate::components::login::ProviderLoginContext;
use crate::pages::{
AdminHomePage, AdminProviderPage, AdminScholarshipPage, AdminShell, AdminUtilsPage,
ApplicantsPageFallback, ApplicantsStudentList, AuthCallbackPage, LoanerBorrowForm,
LoanerFallback, LoanerReturnForm, LoanerShell, ProviderContactPage, ProviderPortal,
ScholarshipInfoPage,
};
use crate::pages::{AdminApplicantsPageFallback, AdminApplicantsPageShell, AdminApplicantsStudentList, AdminHomePage, AdminProviderPage, AdminScholarshipPage, AdminShell, AdminUtilsPage, ApplicantsPageFallback, ApplicantsStudentList, AuthCallbackPage, LoanerBorrowForm, LoanerFallback, LoanerReturnForm, LoanerShell, ProviderContactPage, ProviderPortal, ScholarshipInfoPage};
use crate::pages::{ApplicantsPageShell, student};
use leptos::prelude::*;
use leptos_meta::{MetaTags, Stylesheet, Title, provide_meta_context};
@@ -126,6 +121,10 @@ fn AdminRoutes() -> impl MatchNestedRoutes + Clone {
<Route path=path!("providers") view=AdminProviderPage />
<Route path=path!("scholarships") view=AdminScholarshipPage />
<Route path=path!("utilities") view=AdminUtilsPage />
<ParentRoute path=path!("applicants") view=AdminApplicantsPageShell>
<Route path=path!("") view=AdminApplicantsPageFallback />
<Route path=path!("/:scholarship_id") view=AdminApplicantsStudentList />
</ParentRoute>
</ParentRoute>
}
.into_inner()
+1 -1
View File
@@ -25,6 +25,6 @@ pub struct UserClaims {
/// The intended use for the token.
pub token_use: String,
/// The list of the names of user pool groups that have this user as a member.
#[serde(rename = "cognito:groups")]
#[serde(rename = "cognito:groups", default = "Vec::new")]
pub groups: Vec<String>,
}
+1 -1
View File
@@ -14,7 +14,7 @@ pub fn ActionButton(
r#type=button_type
disabled=move || disabled.get()
>
{ children() }
{children()}
</button>
}
}
+577
View File
@@ -0,0 +1,577 @@
use crate::common::{
ComparisonData, ExpandableInfo, SchemaContainerStyle, SchemaHeaderStyle, SchemaNode,
SchemaType, ValueType,
};
use crate::components::{ActionButton, Banner, DataDisplay, Loading};
use crate::pages::{StudentInformationDialog, UnauthenticatedPage};
use crate::pages::api::get_comparison_info;
use crate::pages::api::students::{GetStudentFiles, admin_get_all_input_files, get_student_data};
use base64::Engine;
use leptos::ev::{Event, MouseEvent};
use leptos::html::Dialog;
use leptos::logging::debug_log;
use leptos::prelude::*;
use leptos::wasm_bindgen::JsCast;
use leptos::web_sys;
use leptos::web_sys::{HtmlAnchorElement, ScrollBehavior};
use leptos_icons::Icon;
use leptos_oidc::{AuthLoaded, AuthSignal, Authenticated};
use leptos_router::components::Outlet;
use leptos_router::hooks::use_navigate;
use std::collections::HashMap;
use leptos_router::NavigateOptions;
#[component]
pub fn AdminApplicantsPageFallback() -> impl IntoView {
view! {
<div class="flex flex-col flex-1 gap-2">
<h1 class="flex-1 text-center font-bold text-2xl">"Scholarship Applicants"</h1>
<p class="text-center text-lg">
"Please select a scholarship from the left to view the eligible applicants."
</p>
</div>
}
}
#[component]
pub fn AdminApplicantsPageShell() -> impl IntoView {
// This shell needs to simply contain everything that will stay consistent across paths.
// The path will change to contain each scholarship's id, which will directly affect the content
// shown in a subroute for this page.
// As a result, this page likely only needs to contain the banner and the list of scholarships.
// The content will be shown within the right side, but only as a result of some subroutes,
// each of which is still considered a single page.
view! {
<div class="flex-1" />
<div class="flex-3 flex flex-row items-start">
<AdminApplicantsScholarshipList />
<div class="flex-3 text-center">
<Outlet />
</div>
</div>
<div class="flex-1" />
}
}
#[component]
fn AdminApplicantsScholarshipList() -> impl IntoView {
use crate::common::ExpandableInfo;
#[server]
async fn admin_get_provider_scholarships(
access_token: String,
) -> Result<Vec<ExpandableInfo>, ServerFnError> {
use crate::pages::api::SCHOLARSHIPS_TABLE;
use crate::pages::api::tokens::validate_and_get_token_info;
let claims = validate_and_get_token_info(access_token, "us-east-1_rvCU4Xy4j", "us-east-1").await?;
// Get the information from the database.
let client = crate::utils::server::create_dynamo_client().await;
let output = client
.scan()
.table_name(SCHOLARSHIPS_TABLE)
.send()
.await?
.items
.unwrap_or_default()
.into_iter()
.filter_map(|item| serde_dynamo::from_item(item).ok())
.collect::<Vec<ExpandableInfo>>();
Ok(output)
}
// Use access token for user identification
let auth = expect_context::<AuthSignal>();
let access_token =
Memo::new(move |_| auth.with(|auth| auth.authenticated().map(|a| a.access_token())));
// We need to get the scholarships from the API. We don't need the whole scholarship, just the
// name and ID.
let trigger = Trigger::new();
let scholarships_res = Resource::new(
move || (trigger.track(), access_token.get()),
async move |(_, access_token)| {
admin_get_provider_scholarships(access_token.unwrap_or_default()).await
},
);
let navigate = use_navigate();
// This closure returns another closure that's using the correct scholarship ID.
// 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| {
let path = format!("/admin/applicants/{}", scholarship_id);
navigate(&*path, Default::default());
});
view! {
<div class="flex flex-col flex-1 rounded-md shadow-lg/33 p-2 gap-3">
<h2 class="text-xl font-bold flex-1 text-center">"Scholarships"</h2>
<Transition fallback=Loading>
{move || {
scholarships_res
.get()
.map(|items_res| {
let items = match items_res {
Ok(items) => items,
Err(e) => {
return view! {
<div>
{format!("Couldn't get scholarships: {}", e.to_string())}
</div>
}
.into_any();
}
};
view! {
<For
each=move || items.clone()
key=|item| item.subject.clone()
let(item)
>
<ApplicantsScholarshipEntry
item=item
on_view_click=on_view_click
/>
</For>
}
.into_any()
})
.collect_view()
}}
</Transition>
</div>
}
}
#[component]
fn ApplicantsScholarshipEntry(
#[prop()] item: ExpandableInfo,
#[prop(into)] on_view_click: Callback<String, ()>,
) -> impl IntoView {
// Store the item's subject.
let subject = StoredValue::new(item.subject);
let scholarship_name = Memo::new(move |_| {
item.data
.get("name")
.map(|name| name.as_string().ok().flatten())
.flatten()
.unwrap_or("<unnamed scholarship>".to_string())
});
let on_click = move |_| {
on_view_click.run(subject.get_value());
};
view! {
<div class="flex flex-col rounded-md shadow-md hover:shadow-lg/33 transition-shadow">
<div class="text-center p-2">{scholarship_name}</div>
<div
class="flex-1 font-bold text-white rounded-b-md text-center cursor-pointer bg-red-800 hover:bg-red-900 p-2 transition-bg"
on:click=on_click
>
"View Applicants"
</div>
</div>
}
}
#[component]
pub fn AdminApplicantsStudentList() -> impl IntoView {
use crate::pages::api::get_scholarship_info;
use crate::pages::api::students::admin_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<String>,
}
// 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::<StudentListParams>();
let auth = expect_context::<AuthSignal>();
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<String, HashMap<String, ValueType>>,
ExpandableInfo,
Vec<ComparisonData>,
),
ServerFnError,
> {
let (students, scholarships, requirements_list) = tokio::join!(
admin_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! {
<div class="flex flex-col flex-1">
<Suspense fallback=Loading>
{move || {
resource
.get()
.map(|result| {
match result {
Ok((students, scholarship, requirements_list)) => {
view! {
<h1 class="text-2xl font-bold">"Eligible Students"</h1>
<div class="text-lg">
"Click on a student's name to view their information and download any requested files."
</div>
<div class="text-lg">
"If your scholarship requires a transcript, please send an email to Sara Smith at "
<a
class="text-blue-500 underline"
href="mailto:ssmith@region15.org"
>
"ssmith@region15.org"
</a>"."
</div>
<AdminApplicantsStudentListView
access_token=access_token
students=students
scholarship=scholarship
requirements=requirements_list
/>
}
.into_any()
}
Err(e) => {
view! {
<div>
"Error while getting eligible students: "{e.to_string()}
</div>
}
.into_any()
}
}
})
}}
</Suspense>
</div>
}
}
#[component]
fn AdminApplicantsStudentListView(
#[prop(into)] access_token: Signal<Option<String>>,
#[prop(into)] students: Signal<HashMap<String, HashMap<String, ValueType>>>,
#[prop(into)] scholarship: Signal<ExpandableInfo>,
#[prop(into)] requirements: Signal<Vec<ComparisonData>>,
) -> 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::<String>("student_id");
let current_student_id = RwSignal::new(None);
let navigate_student = Callback::new(move |id: String| {
let options = web_sys::ScrollToOptions::new();
options.set_top(0.0);
options.set_behavior(ScrollBehavior::Smooth);
if let Some(window) = web_sys::window() {
window.scroll_to_with_scroll_to_options(&options);
}
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);
});
let fafsa_required = Memo::new(move |_| {
let required = scholarship
.get()
.data
.get("fafsa_required")
.map(|v| v.as_string().ok().flatten())
.flatten()
.unwrap_or_default();
required == "Yes"
});
let essay_required = Memo::new(move |_| {
let prompt = scholarship
.get()
.data
.get("essay_prompt")
.map(|v| v.as_string().ok().flatten())
.flatten()
.unwrap_or_default();
!prompt.is_empty()
});
let fafsa_resource = Resource::new(
move || (fafsa_required.get(), access_token.get()),
move |(fafsa_required, access_token)| async move {
// Only get this information if the FAFSA is required. Otherwise, return a blank result.
let Some(access_token) = access_token else {
return Err(ServerFnError::new("Couldn't find access token"));
};
if !fafsa_required {
return Ok(HashMap::new());
}
admin_get_all_input_files(
access_token,
"financial_info".to_string(),
"fafsa".to_string(),
)
.await
},
);
let essay_resource = Resource::new(
move || (essay_required.get(), access_token.get()),
move |(essay_required, access_token)| async move {
// Only get this information if the scholarship requested it. Otherwise, return a blank
// result.
let Some(access_token) = access_token else {
return Err(ServerFnError::new("Couldn't find access token"));
};
if !essay_required {
return Ok(HashMap::new());
}
admin_get_all_input_files(
access_token,
"scholarship_essays".to_string(),
scholarship.get().subject,
)
.await
},
);
fn get_eligible_students(
students: HashMap<String, HashMap<String, ValueType>>,
scholarship: ExpandableInfo,
requirements: Vec<ComparisonData>,
essay_required: bool,
fafsa_required: bool,
fafsa_list: HashMap<String, Vec<String>>,
essay_list: HashMap<String, Vec<String>>,
) -> Result<HashMap<String, HashMap<String, ValueType>>, Error> {
// 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::<Vec<String>>();
Some(string_list)
})
.collect::<Vec<Vec<String>>>();
// 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::<Vec<ComparisonData>>()
})
.collect::<Vec<Vec<ComparisonData>>>();
// 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.
// The easiest ones to check first are whether the scholarship requires a student
// essay and/or their fafsa information. If the student does not have these things,
// they are skipped.
if fafsa_required && !fafsa_list.contains_key(&id) {
// Get the student files related to their fafsa and continue. Return false if not there.
debug_log!(
"Student with id {id} failed - FAFSA is required but was not found."
);
return None;
}
if essay_required && !essay_list.contains_key(&id) {
// Get the student's files related to their essays and continue. Return false if not there.
debug_log!(
"Student with id {id} failed - essay is required but was not found."
);
return None;
}
let result = resolved_requirements.iter().all(|list| {
if list.is_empty() {
return true;
}
list.iter().any(|requirement| {
let result = requirement.compare(&student).unwrap_or(false);
if !result {
debug_log!(
"Student with id {id} failed - requirement with id {} failed.",
requirement.id
);
}
result
})
});
if result { Some((id, student)) } else { None }
})
.collect::<HashMap<String, HashMap<String, ValueType>>>();
// debug_log!("Total number of eligible students: {}", eligible_students.len());
Ok(eligible_students)
}
Effect::new(move || {
// We have to wait until we've gotten fafsa/essay requirements.
debug_log!("Waiting on essay and FAFSA resources...");
if let (Some(fafsa_list), Some(essay_list)) = (fafsa_resource.get(), essay_resource.get()) {
debug_log!(
"Running eligibility check on {} students",
students.get().len()
);
let eligible_res = get_eligible_students(
students.get(),
scholarship.get(),
requirements.get(),
essay_required.get(),
fafsa_required.get(),
fafsa_list.unwrap_or_default(),
essay_list.unwrap_or_default(),
);
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! {
<StudentInformationDialog
scholarship_id=scholarship.get().subject
student_id=current_student_id
visible=dialog_visible
on_close=on_dialog_close
essay_required=essay_required
fafsa_required=fafsa_required
/>
<Show when=move || !error_msg.get().is_empty()>
<div>"Error checking student eligibility: "{error_msg}</div>
</Show>
<div class="m-3 flex flex-col">
<div class="flex flex-row p-1 border-b-1 border-black">
<div class="pl-5 flex-1 text-lg font-bold text-left">"Last Name"</div>
<div class="pr-5 flex-1 text-lg font-bold text-left">"First Name"</div>
</div>
<For
each=move || eligible_students.get()
key=|(id, _)| id.clone()
children=move |(student_id, student)| {
let student = StoredValue::new(student);
let first_name = Memo::new(move |_| {
student
.get_value()
.get("first_name")
.map(|n| n.as_string().ok().flatten())
.unwrap_or_default()
});
let last_name = Memo::new(move |_| {
student
.get_value()
.get("last_name")
.map(|n| n.as_string().ok().flatten())
.unwrap_or_default()
});
let on_click = move |_| {
navigate_student.run(student_id.clone());
};
// For now, we'll just display each student's first and last name.
// Now I'll add a container with some links. These links will just be appended
// to the end of the current path.
view! {
<div
class="flex flex-row p-1 border-y-1 border-gray-200 cursor-pointer hover:bg-yellow-100 transition-all duration-200"
on:click=on_click
>
<div class="pl-5 flex-1 text-lg text-left">{last_name}</div>
<div class="pr-5 flex-1 text-lg text-left">{first_name}</div>
</div>
}
}
/>
</div>
}
}
+2
View File
@@ -3,9 +3,11 @@ mod home_page;
mod provider_page;
mod scholarship_page;
mod utils_page;
mod applicants_list;
pub use shell::*;
pub use home_page::*;
pub use provider_page::*;
pub use scholarship_page::*;
pub use utils_page::*;
pub use applicants_list::*;
+1
View File
@@ -25,6 +25,7 @@ pub fn AdminShell() -> impl IntoView {
TabInfo::new("Providers", "providers", None),
TabInfo::new("Scholarships", "scholarships", None),
TabInfo::new("Utilities", "utilities", None),
TabInfo::new("Scholarship Applicants", "applicants", None),
]
/>
</Authenticated>
+32
View File
@@ -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<DateInfo> {
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! {
<div class="mx-auto px-6">
<div class="flex flex-col gap-4">
@@ -109,6 +140,7 @@ pub fn AdminUtilsPage() -> impl IntoView {
<ActionButton on:click=on_click_comparisons>"Create Comparisons"</ActionButton>
<ActionButton on:click=on_click_dates>"Create Dates"</ActionButton>
<ActionButton on:click=on_click_export>"Scholarship Export"</ActionButton>
<ActionButton on:click=on_click_json>"Get student JSON"</ActionButton>
</div>
</div>
}.into_any()
+3 -3
View File
@@ -66,7 +66,7 @@ pub async fn upload_file(data: MultipartData) -> Result<String, ServerFnError> {
return Err(ServerFnError::new("Missing file contents"));
}
let user_claims = validate_and_get_token_info(access_token).await?;
let user_claims = validate_and_get_token_info(access_token, "us-east-1_Lfjuy5zaM", "us-east-1").await?;
let subject = user_claims.subject;
let key = format!("{form_id}/{subject}/{input_name}/{file_name}");
@@ -157,7 +157,7 @@ pub async fn delete_file(
) -> Result<String, ServerFnError> {
use imports::*;
let user_claims = validate_and_get_token_info(access_token).await?;
let user_claims = validate_and_get_token_info(access_token, "us-east-1_Lfjuy5zaM", "us-east-1").await?;
let subject = user_claims.subject;
let entry_hk = format!("STUDENT#{subject}");
@@ -226,7 +226,7 @@ pub async fn list_files(
) -> Result<Vec<String>, ServerFnError> {
use imports::*;
let user_claims = validate_and_get_token_info(access_token).await?;
let user_claims = validate_and_get_token_info(access_token, "us-east-1_Lfjuy5zaM", "us-east-1").await?;
let subject = user_claims.subject;
let entry_hk = format!("STUDENT#{subject}");
+282 -25
View File
@@ -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 {
@@ -12,10 +13,14 @@ mod imports {
pub use aws_sdk_dynamodb::error::ProvideErrorMetadata;
pub use aws_sdk_dynamodb::types::AttributeValue;
pub use leptos::logging::{debug_log, error};
pub use leptos::serde_json;
pub use std::collections::HashMap;
pub use std::process::Command;
pub use zip::write::SimpleFileOptions;
}
static PDF_TEMPLATE: &str = include_str!("../../../pdf_template.typ");
#[server(input = Json)]
pub async fn put_student_data(
subject: String,
@@ -137,24 +142,43 @@ pub async fn get_all_student_data(
}
#[server]
pub async fn get_completed_students(
pub async fn provider_get_completed_students(
access_token: String,
) -> Result<HashMap<String, HashMap<String, crate::common::ValueType>>, ServerFnError> {
use imports::*;
let claims =
validate_and_get_token_info(access_token, "us-east-1_Lfjuy5zaM", "us-east-1").await?;
if !claims.groups.contains(&"ScholarshipProviders".to_string()) {
return Err(ServerFnError::new(
"User is not in the ScholarshipProviders group",
));
}
get_completed_students().await
}
#[server]
pub async fn admin_get_completed_students(
access_token: String,
) -> Result<HashMap<String, HashMap<String, crate::common::ValueType>>, ServerFnError> {
use imports::*;
let _ = validate_and_get_token_info(access_token, "us-east-1_rvCU4Xy4j", "us-east-1").await?;
get_completed_students().await
}
#[server]
async fn get_completed_students()
-> Result<HashMap<String, HashMap<String, crate::common::ValueType>>, 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
@@ -229,7 +253,8 @@ pub async fn get_file_by_key(
) -> Result<Vec<u8>, ServerFnError> {
use imports::*;
let user_claims = validate_and_get_token_info(access_token).await?;
let user_claims =
validate_and_get_token_info(access_token, "us-east-1_Lfjuy5zaM", "us-east-1").await?;
// Check if the user's subject is contained in the file_key (since all files are keyed by the
// user's subject)
@@ -275,18 +300,59 @@ pub async fn get_student_files(
student_id: String,
form_name: String,
question_id: String,
) -> Result<Vec<u8>, ServerFnError> {
file_name_postfix: String,
) -> Result<(String, Vec<u8>), ServerFnError> {
use imports::*;
let claims = validate_and_get_token_info(access_token).await?;
// Check if the user has permission to get these files.
match validate_and_get_token_info(access_token.clone(), "us-east-1_Lfjuy5zaM", "us-east-1")
.await
{
Ok(claims) => {
if !claims.groups.contains(&"ScholarshipProviders".to_string()) {
return Err(ServerFnError::new("Access denied: user is not a provider"));
}
}
Err(_) => {
let _ = validate_and_get_token_info(access_token, "us-east-1_rvCU4Xy4j", "us-east-1")
.await?;
}
};
let client = create_dynamo_client().await;
let demographics_res = 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)
})?;
let student_demographics = demographics_res
.items
.unwrap_or_default()
.into_iter()
.next()
.ok_or(ServerFnError::new(
"Failed to find student demographic information.",
))?;
let first_name = student_demographics
.get("first_name")
.and_then(|v| v.as_s().ok().cloned())
.unwrap_or_default();
let last_name = student_demographics
.get("last_name")
.and_then(|v| v.as_s().ok().cloned())
.unwrap_or_default();
let file_keys = client
.query()
.table_name(MAIN_TABLE_NAME)
@@ -346,9 +412,25 @@ pub async fn get_student_files(
debug_log!("Writing {} files to zip file...", results.len());
for (key, file_bytes) in results {
let file_name = key.split('/').last().unwrap_or_default();
let file_name = key
.split('/')
.last()
.ok_or(ServerFnError::new("Failed to parse file name"))?
.split(".")
.next()
.unwrap_or_default();
let file_ext = key
.split('.')
.last()
.ok_or(ServerFnError::new("Failed to parse file extension"))?;
// Construct the real file name. This name should be under 150 characters.
let temp = format!("{first_name} {last_name} - {file_name}");
let file_name_no_ext = temp.chars().take(150).collect::<String>();
let file_name_complete = format!("{file_name_no_ext}.{file_ext}");
writer
.start_file(file_name, SimpleFileOptions::default())
.start_file(file_name_complete, SimpleFileOptions::default())
.map_err(ServerFnError::new)?;
writer.write_all(&file_bytes).map_err(ServerFnError::new)?;
}
@@ -356,7 +438,9 @@ pub async fn get_student_files(
debug_log!("Getting finished file");
let finished = writer.finish().map_err(ServerFnError::new)?;
Ok(finished.into_inner())
let file_name_hint = format!("{first_name}{last_name}_{file_name_postfix}.zip");
Ok((file_name_hint, finished.into_inner()))
}
/// # Get All Input Files API
@@ -368,19 +452,12 @@ pub async fn get_student_files(
///
/// This is only usable by users with provider-level access.
#[server]
pub async fn get_all_input_files(
access_token: String,
async fn get_all_input_files(
form_name: String,
input_name: String,
) -> Result<HashMap<String, Vec<String>>, ServerFnError> {
use imports::*;
let claims = validate_and_get_token_info(access_token).await?;
if !claims.groups.contains(&"ScholarshipProviders".to_string()) {
return Err(ServerFnError::new("Access denied: user is not a provider"));
}
let client = create_dynamo_client().await;
let res = client
@@ -421,3 +498,183 @@ pub async fn get_all_input_files(
Ok(result_map)
}
#[server]
pub async fn admin_get_all_input_files(
access_token: String,
form_name: String,
input_name: String,
) -> Result<HashMap<String, Vec<String>>, ServerFnError> {
use imports::*;
let _ = validate_and_get_token_info(access_token, "us-east-1_rvCU4Xy4j", "us-east-1").await?;
get_all_input_files(form_name, input_name).await
}
#[server]
pub async fn provider_get_all_input_files(
access_token: String,
form_name: String,
input_name: String,
) -> Result<HashMap<String, Vec<String>>, ServerFnError> {
use imports::*;
let claims =
validate_and_get_token_info(access_token, "us-east-1_Lfjuy5zaM", "us-east-1").await?;
if !claims.groups.contains(&"ScholarshipProviders".to_string()) {
return Err(ServerFnError::new("Access denied: user is not a provider"));
}
get_all_input_files(form_name, input_name).await
}
#[server]
pub async fn get_student_pdf(student_id: String) -> Result<(String, Vec<u8>), 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("))", "),)")
.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<String, ServerFnError> {
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)
}
+11 -2
View File
@@ -17,10 +17,19 @@ pub async fn fetch_jwks(user_pool_id: String, region: String) -> Result<JwkSet,
/// Validates a JWT, and, if successful, decodes it into a series of user claims.
#[cfg(feature = "ssr")]
pub async fn validate_and_get_token_info(token: String) -> Result<UserClaims, ServerFnError> {
pub async fn validate_and_get_token_info(
token: String,
pool_id: impl Into<String>,
pool_region: impl Into<String>
) -> Result<UserClaims, ServerFnError> {
use jsonwebtoken::{Algorithm, Validation, decode};
let jwks = fetch_jwks("us-east-1_Lfjuy5zaM".into(), "us-east-1".into()).await?;
let pool_id = pool_id.into();
leptos::logging::debug_log!("Validating token using group {}", pool_id);
// let jwks = fetch_jwks("us-east-1_Lfjuy5zaM".into(), "us-east-1".into()).await?;
let jwks = fetch_jwks(pool_id, pool_region.into()).await?;
let validation = Validation::new(Algorithm::RS256);
// validation.set_audience(&["scholarships-rs"]);
+55 -14
View File
@@ -5,7 +5,9 @@
use crate::components::{ActionButton, Banner, DataDisplay, Loading};
use crate::pages::UnauthenticatedPage;
use crate::pages::api::get_comparison_info;
use crate::pages::api::students::{GetStudentFiles, get_all_input_files, get_student_data};
use crate::pages::api::students::{
GetStudentFiles, GetStudentPdf, get_student_data, provider_get_all_input_files,
};
use base64::Engine;
use leptos::ev::{Event, MouseEvent};
use leptos::html::Dialog;
@@ -76,7 +78,12 @@ fn ApplicantsScholarshipList() -> impl IntoView {
use crate::pages::api::SCHOLARSHIPS_TABLE;
use crate::pages::api::tokens::validate_and_get_token_info;
let claims = validate_and_get_token_info(access_token).await?;
let claims = validate_and_get_token_info(
access_token,
"us-east-1_Lfjuy5zaM".to_string(),
"us-east-1".to_string(),
)
.await?;
// Get the information from the database.
let client = crate::utils::server::create_dynamo_client().await;
@@ -110,7 +117,7 @@ fn ApplicantsScholarshipList() -> impl IntoView {
let trigger = Trigger::new();
let scholarships_res = Resource::new(
move || (trigger.track(), access_token.get()),
async move |(_, access_token)| {
move |(_, access_token)| async move {
get_provider_scholarships(access_token.unwrap_or_default()).await
},
);
@@ -201,7 +208,7 @@ fn ApplicantsScholarshipEntry(
#[component]
pub fn ApplicantsStudentList() -> impl IntoView {
use crate::pages::api::get_scholarship_info;
use crate::pages::api::students::get_completed_students;
use crate::pages::api::students::provider_get_completed_students;
use leptos_router::hooks::use_params;
use leptos_router::params::Params;
@@ -241,7 +248,7 @@ pub fn ApplicantsStudentList() -> impl IntoView {
ServerFnError,
> {
let (students, scholarships, requirements_list) = tokio::join!(
get_completed_students(token),
provider_get_completed_students(token),
get_scholarship_info(scholarship_id),
get_comparison_info()
);
@@ -374,7 +381,7 @@ fn ApplicantsStudentListView(
return Ok(HashMap::new());
}
get_all_input_files(
provider_get_all_input_files(
access_token,
"financial_info".to_string(),
"fafsa".to_string(),
@@ -394,7 +401,7 @@ fn ApplicantsStudentListView(
return Ok(HashMap::new());
}
get_all_input_files(
provider_get_all_input_files(
access_token,
"scholarship_essays".to_string(),
scholarship.get().subject,
@@ -479,9 +486,18 @@ fn ApplicantsStudentListView(
if list.is_empty() {
return true;
}
list.iter().any(|requirement| {
let result = requirement.compare(&student).unwrap_or(false);
list.iter()
.any(|requirement| requirement.compare(&student).unwrap_or(false))
if !result {
debug_log!(
"Student with id {id} failed - requirement with id {} failed.",
requirement.id
);
}
result
})
});
if result { Some((id, student)) } else { None }
@@ -583,7 +599,7 @@ fn ApplicantsStudentListView(
}
#[component]
fn StudentInformationDialog(
pub fn StudentInformationDialog(
#[prop(into)] scholarship_id: Signal<String>,
#[prop(into)] student_id: Signal<Option<String>>,
#[prop(into)] visible: Signal<bool>,
@@ -1112,13 +1128,16 @@ fn StudentInformationDialog(
// scholarship, meaning their essays and/or FAFSA by request.
let get_student_files = ServerAction::<GetStudentFiles>::new();
let get_buttons_disabled = get_student_files.pending();
let get_student_pdf = ServerAction::<GetStudentPdf>::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(),
student_id: student_id.get().unwrap_or_default(),
form_name: "financial_info".to_string(),
question_id: "fafsa".to_string(),
file_name_postfix: "FafsaInfo".to_string(),
});
};
@@ -1129,11 +1148,18 @@ fn StudentInformationDialog(
student_id: student_id.get().unwrap_or_default(),
form_name: "scholarship_essays".to_string(),
question_id: scholarship_id.get(),
file_name_postfix: "Essays".to_string(),
});
};
Effect::new(move || {
if let Some(Ok(file)) = get_student_files.value().get() {
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<u8>) {
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.
@@ -1143,12 +1169,24 @@ fn StudentInformationDialog(
.dyn_into()
.unwrap();
a.set_download("");
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() {
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();
}
});
@@ -1182,6 +1220,9 @@ fn StudentInformationDialog(
"Download Essay File(s)"
</ActionButton>
</Show>
<ActionButton on:click=on_click_pdf disabled=get_buttons_disabled>
"Download Student Application"
</ActionButton>
</div>
{move || Suspend::new(async move {
get_student_data(