mirror of
https://github.com/Xevion/banner.git
synced 2025-12-06 01:14:22 -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).
|
//! 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))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(¶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).
|
/// 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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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(" "));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {}: {}",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
Reference in New Issue
Block a user