mirror of
https://github.com/Xevion/banner.git
synced 2025-12-05 23:14:20 -06:00
feat: by CRN querying, redis caching, fixed deserialization, gcal integration
This commit is contained in:
@@ -1,7 +1,11 @@
|
||||
//! Application state shared across components (bot, web, scheduler).
|
||||
|
||||
use crate::banner::BannerApi;
|
||||
use crate::banner::Course;
|
||||
use anyhow::Result;
|
||||
use redis::AsyncCommands;
|
||||
use redis::Client;
|
||||
use serde_json;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct AppState {
|
||||
@@ -21,4 +25,24 @@ impl AppState {
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -298,6 +298,58 @@ impl BannerApi {
|
||||
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(¶ms)
|
||||
.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).
|
||||
pub async fn get_course_details(&self, term: i32, crn: i32) -> Result<ClassDetails> {
|
||||
let body = serde_json::json!({
|
||||
@@ -337,3 +389,40 @@ fn timestamp_nonce() -> String {
|
||||
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"
|
||||
}
|
||||
|
||||
/// 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)
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ pub struct Course {
|
||||
pub campus_description: String,
|
||||
pub schedule_type_description: String,
|
||||
pub course_title: String,
|
||||
pub credit_hours: i32,
|
||||
pub credit_hours: Option<i32>,
|
||||
pub maximum_enrollment: i32,
|
||||
pub enrollment: i32,
|
||||
pub seats_available: i32,
|
||||
@@ -53,7 +53,9 @@ pub struct Course {
|
||||
pub instructional_method: String,
|
||||
pub instructional_method_description: String,
|
||||
pub section_attributes: Vec<SectionAttribute>,
|
||||
#[serde(default)]
|
||||
pub faculty: Vec<FacultyItem>,
|
||||
#[serde(default)]
|
||||
pub meetings_faculty: Vec<MeetingTimeResponse>,
|
||||
}
|
||||
|
||||
|
||||
@@ -519,6 +519,7 @@ pub struct MeetingTimeResponse {
|
||||
pub category: Option<String>,
|
||||
pub class: String,
|
||||
pub course_reference_number: String,
|
||||
#[serde(default)]
|
||||
pub faculty: Vec<FacultyItem>,
|
||||
pub meeting_time: MeetingTime,
|
||||
pub term: String,
|
||||
|
||||
@@ -12,7 +12,7 @@ pub struct SearchResult {
|
||||
pub page_max_size: i32,
|
||||
pub path_mode: String,
|
||||
pub search_results_config: Vec<SearchResultConfig>,
|
||||
pub data: Vec<Course>,
|
||||
pub data: Option<Vec<Course>>,
|
||||
}
|
||||
|
||||
/// Search result configuration
|
||||
|
||||
@@ -16,6 +16,7 @@ pub struct SearchQuery {
|
||||
subject: Option<String>,
|
||||
title: Option<String>,
|
||||
keywords: Option<Vec<String>>,
|
||||
course_reference_number: Option<String>,
|
||||
open_only: Option<bool>,
|
||||
term_part: Option<Vec<String>>,
|
||||
campus: Option<Vec<String>>,
|
||||
@@ -53,6 +54,12 @@ impl SearchQuery {
|
||||
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
|
||||
pub fn keywords(mut self, keywords: Vec<String>) -> Self {
|
||||
self.keywords = Some(keywords);
|
||||
@@ -165,6 +172,10 @@ impl SearchQuery {
|
||||
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 {
|
||||
params.insert("txt_keywordlike".to_string(), keywords.join(" "));
|
||||
}
|
||||
|
||||
@@ -112,6 +112,9 @@ impl CourseScraper {
|
||||
.offset(offset)
|
||||
.max_results(MAX_PAGE_SIZE * 2);
|
||||
|
||||
// Ensure session term is selected before searching
|
||||
self.api.select_term(term).await?;
|
||||
|
||||
let result = self
|
||||
.api
|
||||
.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;
|
||||
|
||||
debug!(
|
||||
@@ -139,7 +142,7 @@ impl CourseScraper {
|
||||
);
|
||||
|
||||
// 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 {
|
||||
error!(
|
||||
"Failed to store course {}: {}",
|
||||
|
||||
@@ -8,7 +8,7 @@ use tracing::{error, info};
|
||||
use url::Url;
|
||||
|
||||
/// 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(
|
||||
ctx: Context<'_>,
|
||||
#[description = "Course Reference Number (CRN)"] crn: i32,
|
||||
@@ -25,43 +25,16 @@ pub async fn gcal(
|
||||
let current_term_status = Term::get_current();
|
||||
let term = current_term_status.inner();
|
||||
|
||||
// TODO: Replace with actual course data when BannerApi::get_course is implemented
|
||||
let course = Course {
|
||||
id: 0,
|
||||
term: term.to_string(),
|
||||
term_desc: term.to_long_string(),
|
||||
course_reference_number: crn.to_string(),
|
||||
part_of_term: "1".to_string(),
|
||||
course_number: "0000".to_string(),
|
||||
subject: "CS".to_string(),
|
||||
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![],
|
||||
// Fetch live course data from Redis cache via AppState
|
||||
let course = match app_state
|
||||
.get_course_or_fetch(&term.to_string(), &crn.to_string())
|
||||
.await
|
||||
{
|
||||
Ok(course) => course,
|
||||
Err(e) => {
|
||||
error!(%e, crn, "Failed to fetch course data");
|
||||
return Err(Error::from(e));
|
||||
}
|
||||
};
|
||||
|
||||
// Get meeting times
|
||||
|
||||
@@ -46,6 +46,10 @@ async fn main() {
|
||||
// Create BannerApi and AppState
|
||||
let banner_api =
|
||||
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 =
|
||||
AppState::new(banner_api, &config.redis_url).expect("Failed to create AppState");
|
||||
|
||||
Reference in New Issue
Block a user