feat: by CRN querying, redis caching, fixed deserialization, gcal integration

This commit is contained in:
2025-08-27 11:12:08 -05:00
parent ede064be87
commit 2ec899cf25
9 changed files with 149 additions and 42 deletions

View File

@@ -1,7 +1,11 @@
//! Application state shared across components (bot, web, scheduler). //! Application state shared across components (bot, web, scheduler).
use crate::banner::BannerApi; use crate::banner::BannerApi;
use crate::banner::Course;
use anyhow::Result;
use redis::AsyncCommands;
use redis::Client; use redis::Client;
use serde_json;
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct AppState { pub struct AppState {
@@ -21,4 +25,24 @@ impl AppState {
redis: std::sync::Arc::new(redis_client), redis: std::sync::Arc::new(redis_client),
}) })
} }
/// Get a course by CRN with Redis cache fallback to Banner API
pub async fn get_course_or_fetch(&self, term: &str, crn: &str) -> Result<Course> {
let mut conn = self.redis.get_multiplexed_async_connection().await?;
let key = format!("class:{}", crn);
if let Some(serialized) = conn.get::<_, Option<String>>(&key).await? {
let course: Course = serde_json::from_str(&serialized)?;
return Ok(course);
}
// Fallback: fetch from Banner API
if let Some(course) = self.banner_api.get_course_by_crn(term, crn).await? {
let serialized = serde_json::to_string(&course)?;
let _: () = conn.set(&key, serialized).await?;
return Ok(course);
}
Err(anyhow::anyhow!("Course not found for CRN {}", crn))
}
} }

View File

@@ -298,6 +298,58 @@ impl BannerApi {
self.session_manager.select_term(term).await self.session_manager.select_term(term).await
} }
/// Retrieves a single course by CRN by issuing a minimal search
pub async fn get_course_by_crn(&self, term: &str, crn: &str) -> Result<Option<Course>> {
self.session_manager.reset_data_form().await?;
// Ensure session is configured for this term
self.select_term(term).await?;
let session_id = self.session_manager.ensure_session()?;
let query = SearchQuery::new()
.course_reference_number(crn)
.max_results(1);
let mut params = query.to_params();
params.insert("txt_term".to_string(), term.to_string());
params.insert("uniqueSessionId".to_string(), session_id);
params.insert("sortColumn".to_string(), "subjectDescription".to_string());
params.insert("sortDirection".to_string(), "asc".to_string());
params.insert("startDatepicker".to_string(), String::new());
params.insert("endDatepicker".to_string(), String::new());
let url = format!("{}/searchResults/searchResults", self.base_url);
let response = self
.client
.get(&url)
.query(&params)
.send()
.await
.context("Failed to search course by CRN")?;
let status = response.status();
let body = response
.text()
.await
.with_context(|| format!("Failed to read body (status={status})"))?;
let search_result: SearchResult = parse_json_with_context(&body).map_err(|e| {
anyhow::anyhow!(
"Failed to parse search response for CRN (status={status}, url={url}): {e}",
)
})?;
if !search_result.success {
return Err(anyhow::anyhow!(
"Search marked as unsuccessful by Banner API"
));
}
Ok(search_result
.data
.and_then(|courses| courses.into_iter().next()))
}
/// Gets course details (placeholder - needs implementation). /// Gets course details (placeholder - needs implementation).
pub async fn get_course_details(&self, term: i32, crn: i32) -> Result<ClassDetails> { pub async fn get_course_details(&self, term: i32, crn: i32) -> Result<ClassDetails> {
let body = serde_json::json!({ let body = serde_json::json!({
@@ -337,3 +389,40 @@ fn timestamp_nonce() -> String {
fn user_agent() -> &'static str { fn user_agent() -> &'static str {
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/113.0.0.0 Safari/537.36" "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/113.0.0.0 Safari/537.36"
} }
/// Attempt to parse JSON and, on failure, include a contextual snippet around the error location
fn parse_json_with_context<T: serde::de::DeserializeOwned>(body: &str) -> Result<T> {
match serde_json::from_str::<T>(body) {
Ok(value) => Ok(value),
Err(err) => {
let (line, column) = (err.line(), err.column());
let snippet = build_error_snippet(body, line as usize, column as usize, 120);
Err(anyhow::anyhow!(
"{} at line {}, column {}\nSnippet:\n{}",
err,
line,
column,
snippet
))
}
}
}
fn build_error_snippet(body: &str, line: usize, column: usize, max_len: usize) -> String {
let target_line = body.lines().nth(line.saturating_sub(1)).unwrap_or("");
if target_line.is_empty() {
return String::new();
}
let start = column.saturating_sub(max_len.min(column));
let end = (column + max_len).min(target_line.len());
let slice = &target_line[start..end];
let mut indicator = String::new();
if column > start {
indicator.push_str(&" ".repeat(column - start - 1));
indicator.push('^');
}
format!("{}\n{}", slice, indicator)
}

View File

@@ -32,7 +32,7 @@ pub struct Course {
pub campus_description: String, pub campus_description: String,
pub schedule_type_description: String, pub schedule_type_description: String,
pub course_title: String, pub course_title: String,
pub credit_hours: i32, pub credit_hours: Option<i32>,
pub maximum_enrollment: i32, pub maximum_enrollment: i32,
pub enrollment: i32, pub enrollment: i32,
pub seats_available: i32, pub seats_available: i32,
@@ -53,7 +53,9 @@ pub struct Course {
pub instructional_method: String, pub instructional_method: String,
pub instructional_method_description: String, pub instructional_method_description: String,
pub section_attributes: Vec<SectionAttribute>, pub section_attributes: Vec<SectionAttribute>,
#[serde(default)]
pub faculty: Vec<FacultyItem>, pub faculty: Vec<FacultyItem>,
#[serde(default)]
pub meetings_faculty: Vec<MeetingTimeResponse>, pub meetings_faculty: Vec<MeetingTimeResponse>,
} }

View File

@@ -519,6 +519,7 @@ pub struct MeetingTimeResponse {
pub category: Option<String>, pub category: Option<String>,
pub class: String, pub class: String,
pub course_reference_number: String, pub course_reference_number: String,
#[serde(default)]
pub faculty: Vec<FacultyItem>, pub faculty: Vec<FacultyItem>,
pub meeting_time: MeetingTime, pub meeting_time: MeetingTime,
pub term: String, pub term: String,

View File

@@ -12,7 +12,7 @@ pub struct SearchResult {
pub page_max_size: i32, pub page_max_size: i32,
pub path_mode: String, pub path_mode: String,
pub search_results_config: Vec<SearchResultConfig>, pub search_results_config: Vec<SearchResultConfig>,
pub data: Vec<Course>, pub data: Option<Vec<Course>>,
} }
/// Search result configuration /// Search result configuration

View File

@@ -16,6 +16,7 @@ pub struct SearchQuery {
subject: Option<String>, subject: Option<String>,
title: Option<String>, title: Option<String>,
keywords: Option<Vec<String>>, keywords: Option<Vec<String>>,
course_reference_number: Option<String>,
open_only: Option<bool>, open_only: Option<bool>,
term_part: Option<Vec<String>>, term_part: Option<Vec<String>>,
campus: Option<Vec<String>>, campus: Option<Vec<String>>,
@@ -53,6 +54,12 @@ impl SearchQuery {
self self
} }
/// Sets the course reference number (CRN) for the query
pub fn course_reference_number<S: Into<String>>(mut self, crn: S) -> Self {
self.course_reference_number = Some(crn.into());
self
}
/// Sets the keywords for the query /// Sets the keywords for the query
pub fn keywords(mut self, keywords: Vec<String>) -> Self { pub fn keywords(mut self, keywords: Vec<String>) -> Self {
self.keywords = Some(keywords); self.keywords = Some(keywords);
@@ -165,6 +172,10 @@ impl SearchQuery {
params.insert("txt_courseTitle".to_string(), title.trim().to_string()); params.insert("txt_courseTitle".to_string(), title.trim().to_string());
} }
if let Some(ref crn) = self.course_reference_number {
params.insert("txt_courseReferenceNumber".to_string(), crn.clone());
}
if let Some(ref keywords) = self.keywords { if let Some(ref keywords) = self.keywords {
params.insert("txt_keywordlike".to_string(), keywords.join(" ")); params.insert("txt_keywordlike".to_string(), keywords.join(" "));
} }

View File

@@ -112,6 +112,9 @@ impl CourseScraper {
.offset(offset) .offset(offset)
.max_results(MAX_PAGE_SIZE * 2); .max_results(MAX_PAGE_SIZE * 2);
// Ensure session term is selected before searching
self.api.select_term(term).await?;
let result = self let result = self
.api .api
.search(term, &query, "subjectDescription", false) .search(term, &query, "subjectDescription", false)
@@ -130,7 +133,7 @@ impl CourseScraper {
)); ));
} }
let course_count = result.data.len() as i32; let course_count = result.data.as_ref().map(|v| v.len() as i32).unwrap_or(0);
total_courses += course_count; total_courses += course_count;
debug!( debug!(
@@ -139,7 +142,7 @@ impl CourseScraper {
); );
// Store each course in Redis // Store each course in Redis
for course in result.data { for course in result.data.unwrap_or_default() {
if let Err(e) = self.store_course(&course).await { if let Err(e) = self.store_course(&course).await {
error!( error!(
"Failed to store course {}: {}", "Failed to store course {}: {}",

View File

@@ -8,7 +8,7 @@ use tracing::{error, info};
use url::Url; use url::Url;
/// Generate a link to create a Google Calendar event for a course /// Generate a link to create a Google Calendar event for a course
#[poise::command(slash_command, prefix_command)] #[poise::command(slash_command)]
pub async fn gcal( pub async fn gcal(
ctx: Context<'_>, ctx: Context<'_>,
#[description = "Course Reference Number (CRN)"] crn: i32, #[description = "Course Reference Number (CRN)"] crn: i32,
@@ -25,43 +25,16 @@ pub async fn gcal(
let current_term_status = Term::get_current(); let current_term_status = Term::get_current();
let term = current_term_status.inner(); let term = current_term_status.inner();
// TODO: Replace with actual course data when BannerApi::get_course is implemented // Fetch live course data from Redis cache via AppState
let course = Course { let course = match app_state
id: 0, .get_course_or_fetch(&term.to_string(), &crn.to_string())
term: term.to_string(), .await
term_desc: term.to_long_string(), {
course_reference_number: crn.to_string(), Ok(course) => course,
part_of_term: "1".to_string(), Err(e) => {
course_number: "0000".to_string(), error!(%e, crn, "Failed to fetch course data");
subject: "CS".to_string(), return Err(Error::from(e));
subject_description: "Computer Science".to_string(), }
sequence_number: "001".to_string(),
campus_description: "Main Campus".to_string(),
schedule_type_description: "Lecture".to_string(),
course_title: "Example Course".to_string(),
credit_hours: 3,
maximum_enrollment: 30,
enrollment: 25,
seats_available: 5,
wait_capacity: 10,
wait_count: 0,
cross_list: None,
cross_list_capacity: None,
cross_list_count: None,
cross_list_available: None,
credit_hour_high: None,
credit_hour_low: None,
credit_hour_indicator: None,
open_section: true,
link_identifier: None,
is_section_linked: false,
subject_course: "CS0000".to_string(),
reserved_seat_summary: None,
instructional_method: "FF".to_string(),
instructional_method_description: "Face to Face".to_string(),
section_attributes: vec![],
faculty: vec![],
meetings_faculty: vec![],
}; };
// Get meeting times // Get meeting times

View File

@@ -46,6 +46,10 @@ async fn main() {
// Create BannerApi and AppState // Create BannerApi and AppState
let banner_api = let banner_api =
BannerApi::new(config.banner_base_url.clone()).expect("Failed to create BannerApi"); BannerApi::new(config.banner_base_url.clone()).expect("Failed to create BannerApi");
banner_api
.setup()
.await
.expect("Failed to set up BannerApi session");
let app_state = let app_state =
AppState::new(banner_api, &config.redis_url).expect("Failed to create AppState"); AppState::new(banner_api, &config.redis_url).expect("Failed to create AppState");