mirror of
https://github.com/Xevion/banner.git
synced 2025-12-05 23:14:20 -06:00
Compare commits
11 Commits
752c855dec
...
cfb847f2e5
| Author | SHA1 | Date | |
|---|---|---|---|
| cfb847f2e5 | |||
| e7d47f1f96 | |||
| 9a48587479 | |||
| 624247ee14 | |||
| 430e2a255b | |||
| bbc78131ec | |||
| 77ab71d4d5 | |||
| 9d720bb0a7 | |||
| dcc564dee6 | |||
| 4ca55a1fd4 | |||
| a6e7adcaef |
88
Cargo.lock
generated
88
Cargo.lock
generated
@@ -168,7 +168,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "banner"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -184,15 +184,16 @@ dependencies = [
|
||||
"futures",
|
||||
"governor",
|
||||
"http 1.3.1",
|
||||
"num-format",
|
||||
"once_cell",
|
||||
"poise",
|
||||
"rand 0.9.2",
|
||||
"redis",
|
||||
"regex",
|
||||
"reqwest 0.12.23",
|
||||
"reqwest-middleware",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_path_to_error",
|
||||
"serenity",
|
||||
"sqlx",
|
||||
"thiserror 2.0.16",
|
||||
@@ -336,20 +337,6 @@ dependencies = [
|
||||
"windows-link 0.2.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "combine"
|
||||
version = "4.6.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"futures-core",
|
||||
"memchr",
|
||||
"pin-project-lite",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "command_attr"
|
||||
version = "0.5.3"
|
||||
@@ -1665,16 +1652,6 @@ dependencies = [
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-bigint"
|
||||
version = "0.4.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9"
|
||||
dependencies = [
|
||||
"num-integer",
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-bigint-dig"
|
||||
version = "0.8.4"
|
||||
@@ -1698,6 +1675,16 @@ version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9"
|
||||
|
||||
[[package]]
|
||||
name = "num-format"
|
||||
version = "0.4.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a652d9771a63711fd3c3deb670acfbe5c30a4072e664d7a3bf5a9e1056ac72c3"
|
||||
dependencies = [
|
||||
"arrayvec",
|
||||
"itoa",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-integer"
|
||||
version = "0.1.46"
|
||||
@@ -2031,17 +2018,6 @@ version = "5.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
|
||||
|
||||
[[package]]
|
||||
name = "r2d2"
|
||||
version = "0.8.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "51de85fb3fb6524929c8a2eb85e6b6d363de4e8c48f9e2c2eac4944abc181c93"
|
||||
dependencies = [
|
||||
"log",
|
||||
"parking_lot",
|
||||
"scheduled-thread-pool",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.8.5"
|
||||
@@ -2110,29 +2086,6 @@ dependencies = [
|
||||
"bitflags 2.9.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "redis"
|
||||
version = "0.32.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7cd3650deebc68526b304898b192fa4102a4ef0b9ada24da096559cb60e0eef8"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"cfg-if",
|
||||
"combine",
|
||||
"futures-util",
|
||||
"itoa",
|
||||
"num-bigint",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"r2d2",
|
||||
"ryu",
|
||||
"sha1_smol",
|
||||
"socket2 0.6.0",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "redox_syscall"
|
||||
version = "0.5.17"
|
||||
@@ -2454,15 +2407,6 @@ dependencies = [
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "scheduled-thread-pool"
|
||||
version = "0.2.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3cbc66816425a074528352f5789333ecff06ca41b36b0b0efdfbb29edc391a19"
|
||||
dependencies = [
|
||||
"parking_lot",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "scopeguard"
|
||||
version = "1.2.0"
|
||||
@@ -2641,12 +2585,6 @@ dependencies = [
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha1_smol"
|
||||
version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d"
|
||||
|
||||
[[package]]
|
||||
name = "sha2"
|
||||
version = "0.10.9"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "banner"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
edition = "2024"
|
||||
default-run = "banner"
|
||||
|
||||
@@ -20,7 +20,6 @@ futures = "0.3"
|
||||
http = "1.3.1"
|
||||
poise = "0.6.1"
|
||||
rand = "0.9.2"
|
||||
redis = { version = "0.32.5", features = ["tokio-comp", "r2d2"] }
|
||||
regex = "1.10"
|
||||
reqwest = { version = "0.12.23", features = ["json", "cookies"] }
|
||||
reqwest-middleware = { version = "0.4.2", features = ["json"] }
|
||||
@@ -43,5 +42,7 @@ tracing-subscriber = { version = "0.3.20", features = ["env-filter", "json"] }
|
||||
url = "2.5"
|
||||
governor = "0.10.1"
|
||||
once_cell = "1.21.3"
|
||||
serde_path_to_error = "0.1.17"
|
||||
num-format = "0.4.4"
|
||||
|
||||
[dev-dependencies]
|
||||
|
||||
@@ -1,16 +1,34 @@
|
||||
//! JSON parsing utilities for the Banner API client.
|
||||
|
||||
use anyhow::Result;
|
||||
use serde_json;
|
||||
|
||||
/// Attempt to parse JSON and, on failure, include a contextual snippet of the
|
||||
/// line where the error occurred. This prevents dumping huge JSON bodies to logs.
|
||||
pub fn parse_json_with_context<T: serde::de::DeserializeOwned>(body: &str) -> Result<T> {
|
||||
match serde_json::from_str::<T>(body) {
|
||||
let jd = &mut serde_json::Deserializer::from_str(body);
|
||||
match serde_path_to_error::deserialize(jd) {
|
||||
Ok(value) => Ok(value),
|
||||
Err(err) => {
|
||||
let (line, column) = (err.line(), err.column());
|
||||
// let snippet = build_error_snippet(body, line, column, 80);
|
||||
Err(anyhow::anyhow!("{err} at line {line}, column {column}",))
|
||||
let inner_err = err.inner();
|
||||
let (line, column) = (inner_err.line(), inner_err.column());
|
||||
let snippet = build_error_snippet(body, line, column, 20);
|
||||
let path = err.path().to_string();
|
||||
|
||||
let msg = inner_err.to_string();
|
||||
let loc = format!(" at line {line} column {column}");
|
||||
let msg_without_loc = msg.strip_suffix(&loc).unwrap_or(&msg).to_string();
|
||||
|
||||
let mut final_err = String::new();
|
||||
if !path.is_empty() && path != "." {
|
||||
final_err.push_str(&format!("for path '{}' ", path));
|
||||
}
|
||||
final_err.push_str(&format!(
|
||||
"({msg_without_loc}) at line {line} column {column}"
|
||||
));
|
||||
final_err.push_str(&format!("\n{snippet}"));
|
||||
|
||||
Err(anyhow::anyhow!(final_err))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ pub struct FacultyItem {
|
||||
#[serde(deserialize_with = "deserialize_string_to_u32")]
|
||||
pub course_reference_number: u32, // CRN, e.g 27294
|
||||
pub display_name: String, // "LastName, FirstName"
|
||||
pub email_address: String, // e.g. FirstName.LastName@utsaedu
|
||||
pub email_address: Option<String>, // e.g. FirstName.LastName@utsaedu
|
||||
pub primary_indicator: bool,
|
||||
pub term: String, // e.g "202420"
|
||||
}
|
||||
@@ -63,7 +63,7 @@ pub struct MeetingTime {
|
||||
pub campus: Option<String>, // campus code, e.g 11
|
||||
pub campus_description: Option<String>, // name of campus, e.g Main Campus
|
||||
pub course_reference_number: String, // CRN, e.g 27294
|
||||
pub credit_hour_session: f64, // e.g. 30
|
||||
pub credit_hour_session: Option<f64>, // e.g. 30
|
||||
pub hours_week: f64, // e.g. 30
|
||||
pub meeting_schedule_type: String, // e.g AFF
|
||||
pub meeting_type: String, // e.g HB, H2, H1, OS, OA, OH, ID, FF
|
||||
@@ -148,6 +148,8 @@ pub enum DayOfWeek {
|
||||
|
||||
impl DayOfWeek {
|
||||
/// Convert to short string representation
|
||||
///
|
||||
/// Do not change these, these are used for ICS generation. Casing does not matter though.
|
||||
pub fn to_short_string(self) -> &'static str {
|
||||
match self {
|
||||
DayOfWeek::Monday => "Mo",
|
||||
|
||||
@@ -4,7 +4,7 @@ use crate::banner::rate_limiter::{RequestType, SharedRateLimiter};
|
||||
use http::Extensions;
|
||||
use reqwest::{Request, Response};
|
||||
use reqwest_middleware::{Middleware, Next};
|
||||
use tracing::{debug, warn, trace};
|
||||
use tracing::{debug, trace, warn};
|
||||
use url::Url;
|
||||
|
||||
/// Middleware that enforces rate limiting based on request URL patterns
|
||||
@@ -90,7 +90,7 @@ impl Middleware for RateLimitMiddleware {
|
||||
}
|
||||
Err(error) => {
|
||||
warn!(
|
||||
url = %error.url().unwrap_or(&Url::parse("unknown").unwrap()),
|
||||
url = ?error.url(),
|
||||
error = ?error,
|
||||
"Request failed"
|
||||
);
|
||||
|
||||
@@ -1,8 +1,111 @@
|
||||
//! ICS command implementation for generating calendar files.
|
||||
|
||||
use crate::banner::{Course, MeetingScheduleInfo};
|
||||
use crate::bot::{Context, Error, utils};
|
||||
use chrono::{Datelike, NaiveDate, Utc};
|
||||
use serenity::all::CreateAttachment;
|
||||
use tracing::info;
|
||||
|
||||
/// Represents a holiday or special day that should be excluded from class schedules
|
||||
#[derive(Debug, Clone)]
|
||||
enum Holiday {
|
||||
/// A single-day holiday
|
||||
Single { month: u32, day: u32 },
|
||||
/// A multi-day holiday range
|
||||
Range {
|
||||
month: u32,
|
||||
start_day: u32,
|
||||
end_day: u32,
|
||||
},
|
||||
}
|
||||
|
||||
impl Holiday {
|
||||
/// Check if a specific date falls within this holiday
|
||||
fn contains_date(&self, date: NaiveDate) -> bool {
|
||||
match self {
|
||||
Holiday::Single { month, day, .. } => date.month() == *month && date.day() == *day,
|
||||
Holiday::Range {
|
||||
month,
|
||||
start_day,
|
||||
end_day,
|
||||
..
|
||||
} => date.month() == *month && date.day() >= *start_day && date.day() <= *end_day,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get all dates in this holiday for a given year
|
||||
fn get_dates_for_year(&self, year: i32) -> Vec<NaiveDate> {
|
||||
match self {
|
||||
Holiday::Single { month, day, .. } => {
|
||||
if let Some(date) = NaiveDate::from_ymd_opt(year, *month, *day) {
|
||||
vec![date]
|
||||
} else {
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
Holiday::Range {
|
||||
month,
|
||||
start_day,
|
||||
end_day,
|
||||
..
|
||||
} => {
|
||||
let mut dates = Vec::new();
|
||||
for day in *start_day..=*end_day {
|
||||
if let Some(date) = NaiveDate::from_ymd_opt(year, *month, day) {
|
||||
dates.push(date);
|
||||
}
|
||||
}
|
||||
dates
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// University holidays that should be excluded from class schedules
|
||||
const UNIVERSITY_HOLIDAYS: &[(&'static str, Holiday)] = &[
|
||||
("Labor Day", Holiday::Single { month: 9, day: 1 }),
|
||||
(
|
||||
"Fall Break",
|
||||
Holiday::Range {
|
||||
month: 10,
|
||||
start_day: 13,
|
||||
end_day: 14,
|
||||
},
|
||||
),
|
||||
(
|
||||
"Unspecified Holiday",
|
||||
Holiday::Single { month: 11, day: 26 },
|
||||
),
|
||||
(
|
||||
"Thanksgiving",
|
||||
Holiday::Range {
|
||||
month: 11,
|
||||
start_day: 28,
|
||||
end_day: 29,
|
||||
},
|
||||
),
|
||||
("Student Study Day", Holiday::Single { month: 12, day: 5 }),
|
||||
(
|
||||
"Winter Holiday",
|
||||
Holiday::Range {
|
||||
month: 12,
|
||||
start_day: 23,
|
||||
end_day: 31,
|
||||
},
|
||||
),
|
||||
("New Year's Day", Holiday::Single { month: 1, day: 1 }),
|
||||
("MLK Day", Holiday::Single { month: 1, day: 20 }),
|
||||
(
|
||||
"Spring Break",
|
||||
Holiday::Range {
|
||||
month: 3,
|
||||
start_day: 10,
|
||||
end_day: 15,
|
||||
},
|
||||
),
|
||||
("Student Study Day", Holiday::Single { month: 5, day: 9 }),
|
||||
];
|
||||
|
||||
/// Generate an ICS file for a course
|
||||
#[poise::command(slash_command, prefix_command)]
|
||||
pub async fn ics(
|
||||
@@ -12,14 +115,322 @@ pub async fn ics(
|
||||
ctx.defer().await?;
|
||||
|
||||
let course = utils::get_course_by_crn(&ctx, crn).await?;
|
||||
let term = course.term.clone();
|
||||
|
||||
// TODO: Implement actual ICS file generation
|
||||
ctx.say(format!(
|
||||
"ICS generation for '{}' is not yet implemented.",
|
||||
course.display_title()
|
||||
))
|
||||
// Get meeting times
|
||||
let meeting_times = ctx
|
||||
.data()
|
||||
.app_state
|
||||
.banner_api
|
||||
.get_course_meeting_time(&term, &crn.to_string())
|
||||
.await?;
|
||||
|
||||
if meeting_times.is_empty() {
|
||||
ctx.say("No meeting times found for this course.").await?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Sort meeting times by start time
|
||||
let mut sorted_meeting_times = meeting_times.to_vec();
|
||||
sorted_meeting_times.sort_unstable_by(|a, b| match (&a.time_range, &b.time_range) {
|
||||
(Some(a_time), Some(b_time)) => a_time.start.cmp(&b_time.start),
|
||||
(Some(_), None) => std::cmp::Ordering::Less,
|
||||
(None, Some(_)) => std::cmp::Ordering::Greater,
|
||||
(None, None) => a.days.bits().cmp(&b.days.bits()),
|
||||
});
|
||||
|
||||
// Generate ICS content
|
||||
let (ics_content, excluded_holidays) =
|
||||
generate_ics_content(&course, &term, &sorted_meeting_times)?;
|
||||
|
||||
// Create file attachment
|
||||
let filename = format!(
|
||||
"{subject}_{number}_{section}.ics",
|
||||
subject = course.subject.replace(" ", "_"),
|
||||
number = course.course_number,
|
||||
section = course.sequence_number,
|
||||
);
|
||||
|
||||
let file = CreateAttachment::bytes(ics_content.into_bytes(), filename.clone());
|
||||
|
||||
// Build response content
|
||||
let mut response_content = format!(
|
||||
"📅 Generated ICS calendar for **{}**\n\n**Meeting Times:**\n{}",
|
||||
course.display_title(),
|
||||
sorted_meeting_times
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, m)| {
|
||||
let time_info = match &m.time_range {
|
||||
Some(range) => format!(
|
||||
"{} {}",
|
||||
m.days_string().unwrap_or("TBA".to_string()),
|
||||
range.format_12hr()
|
||||
),
|
||||
None => m.days_string().unwrap_or("TBA".to_string()),
|
||||
};
|
||||
format!("{}. {}", i + 1, time_info)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
);
|
||||
|
||||
// Add holiday exclusion information
|
||||
if !excluded_holidays.is_empty() {
|
||||
let count = excluded_holidays.len();
|
||||
let count_text = if count == 1 {
|
||||
"1 date was".to_string()
|
||||
} else {
|
||||
format!("{} dates were", count)
|
||||
};
|
||||
response_content.push_str(&format!("\n\n{} excluded from the ICS file:\n", count_text));
|
||||
response_content.push_str(
|
||||
&excluded_holidays
|
||||
.iter()
|
||||
.map(|s| format!("- {}", s))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n"),
|
||||
);
|
||||
}
|
||||
|
||||
ctx.send(
|
||||
poise::CreateReply::default()
|
||||
.content(response_content)
|
||||
.attachment(file),
|
||||
)
|
||||
.await?;
|
||||
|
||||
info!(crn = %crn, "ics command completed");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Generate ICS content for a course and its meeting times
|
||||
fn generate_ics_content(
|
||||
course: &Course,
|
||||
term: &str,
|
||||
meeting_times: &[MeetingScheduleInfo],
|
||||
) -> Result<(String, Vec<String>), anyhow::Error> {
|
||||
let mut ics_content = String::new();
|
||||
let mut excluded_holidays = Vec::new();
|
||||
|
||||
// ICS header
|
||||
ics_content.push_str("BEGIN:VCALENDAR\r\n");
|
||||
ics_content.push_str("VERSION:2.0\r\n");
|
||||
ics_content.push_str("PRODID:-//Banner Bot//Course Calendar//EN\r\n");
|
||||
ics_content.push_str("CALSCALE:GREGORIAN\r\n");
|
||||
ics_content.push_str("METHOD:PUBLISH\r\n");
|
||||
|
||||
// Calendar name
|
||||
ics_content.push_str(&format!(
|
||||
"X-WR-CALNAME:{} - {}\r\n",
|
||||
course.display_title(),
|
||||
term
|
||||
));
|
||||
|
||||
// Generate events for each meeting time
|
||||
for (index, meeting_time) in meeting_times.iter().enumerate() {
|
||||
let (event_content, holidays) = generate_event_content(course, meeting_time, index)?;
|
||||
ics_content.push_str(&event_content);
|
||||
excluded_holidays.extend(holidays);
|
||||
}
|
||||
|
||||
// ICS footer
|
||||
ics_content.push_str("END:VCALENDAR\r\n");
|
||||
|
||||
Ok((ics_content, excluded_holidays))
|
||||
}
|
||||
|
||||
/// Generate ICS event content for a single meeting time
|
||||
fn generate_event_content(
|
||||
course: &Course,
|
||||
meeting_time: &MeetingScheduleInfo,
|
||||
index: usize,
|
||||
) -> Result<(String, Vec<String>), anyhow::Error> {
|
||||
let course_title = course.display_title();
|
||||
let instructor_name = course.primary_instructor_name();
|
||||
let location = meeting_time.place_string();
|
||||
|
||||
// Create event title with meeting index if multiple meetings
|
||||
let event_title = if index > 0 {
|
||||
format!("{} (Meeting {})", course_title, index + 1)
|
||||
} else {
|
||||
course_title
|
||||
};
|
||||
|
||||
// Create event description
|
||||
let description = format!(
|
||||
"CRN: {}\\nInstructor: {}\\nDays: {}\\nMeeting Type: {}",
|
||||
course.course_reference_number,
|
||||
instructor_name,
|
||||
meeting_time.days_string().unwrap_or("TBA".to_string()),
|
||||
meeting_time.meeting_type.description()
|
||||
);
|
||||
|
||||
// Get start and end times
|
||||
let (start_dt, end_dt) = meeting_time.datetime_range();
|
||||
|
||||
// Format datetimes for ICS (UTC format)
|
||||
let start_utc = start_dt.with_timezone(&Utc);
|
||||
let end_utc = end_dt.with_timezone(&Utc);
|
||||
|
||||
let start_str = start_utc.format("%Y%m%dT%H%M%SZ").to_string();
|
||||
let end_str = end_utc.format("%Y%m%dT%H%M%SZ").to_string();
|
||||
|
||||
// Generate unique ID for the event
|
||||
let uid = format!(
|
||||
"{}-{}-{}@banner-bot.local",
|
||||
course.course_reference_number,
|
||||
index,
|
||||
start_utc.timestamp()
|
||||
);
|
||||
|
||||
let mut event_content = String::new();
|
||||
|
||||
// Event header
|
||||
event_content.push_str("BEGIN:VEVENT\r\n");
|
||||
event_content.push_str(&format!("UID:{}\r\n", uid));
|
||||
event_content.push_str(&format!("DTSTART:{}\r\n", start_str));
|
||||
event_content.push_str(&format!("DTEND:{}\r\n", end_str));
|
||||
event_content.push_str(&format!("SUMMARY:{}\r\n", escape_ics_text(&event_title)));
|
||||
event_content.push_str(&format!(
|
||||
"DESCRIPTION:{}\r\n",
|
||||
escape_ics_text(&description)
|
||||
));
|
||||
event_content.push_str(&format!("LOCATION:{}\r\n", escape_ics_text(&location)));
|
||||
|
||||
// Add recurrence rule if there are specific days and times
|
||||
if !meeting_time.days.is_empty() && meeting_time.time_range.is_some() {
|
||||
let days_of_week = meeting_time.days_of_week();
|
||||
let by_day: Vec<String> = days_of_week
|
||||
.iter()
|
||||
.map(|day| day.to_short_string().to_uppercase())
|
||||
.collect();
|
||||
|
||||
if !by_day.is_empty() {
|
||||
let until_date = meeting_time
|
||||
.date_range
|
||||
.end
|
||||
.format("%Y%m%dT000000Z")
|
||||
.to_string();
|
||||
|
||||
event_content.push_str(&format!(
|
||||
"RRULE:FREQ=WEEKLY;BYDAY={};UNTIL={}\r\n",
|
||||
by_day.join(","),
|
||||
until_date
|
||||
));
|
||||
|
||||
// Add holiday exceptions (EXDATE) if the class would meet on holiday dates
|
||||
let holiday_exceptions = get_holiday_exceptions(meeting_time);
|
||||
if let Some(exdate_property) = generate_exdate_property(&holiday_exceptions, start_utc)
|
||||
{
|
||||
event_content.push_str(&format!("{}\r\n", exdate_property));
|
||||
}
|
||||
|
||||
// Collect holiday names for reporting
|
||||
let mut holiday_names = Vec::new();
|
||||
for (holiday_name, holiday) in UNIVERSITY_HOLIDAYS {
|
||||
for &exception_date in &holiday_exceptions {
|
||||
if holiday.contains_date(exception_date) {
|
||||
holiday_names.push(format!(
|
||||
"{} ({})",
|
||||
holiday_name,
|
||||
exception_date.format("%a, %b %d")
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
holiday_names.sort();
|
||||
holiday_names.dedup();
|
||||
|
||||
return Ok((event_content, holiday_names));
|
||||
}
|
||||
}
|
||||
|
||||
// Event footer
|
||||
event_content.push_str("END:VEVENT\r\n");
|
||||
|
||||
Ok((event_content, Vec::new()))
|
||||
}
|
||||
|
||||
/// Convert chrono::Weekday to the custom DayOfWeek enum
|
||||
fn chrono_weekday_to_day_of_week(weekday: chrono::Weekday) -> crate::banner::meetings::DayOfWeek {
|
||||
use crate::banner::meetings::DayOfWeek;
|
||||
match weekday {
|
||||
chrono::Weekday::Mon => DayOfWeek::Monday,
|
||||
chrono::Weekday::Tue => DayOfWeek::Tuesday,
|
||||
chrono::Weekday::Wed => DayOfWeek::Wednesday,
|
||||
chrono::Weekday::Thu => DayOfWeek::Thursday,
|
||||
chrono::Weekday::Fri => DayOfWeek::Friday,
|
||||
chrono::Weekday::Sat => DayOfWeek::Saturday,
|
||||
chrono::Weekday::Sun => DayOfWeek::Sunday,
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if a class meets on a specific date based on its meeting days
|
||||
fn class_meets_on_date(meeting_time: &MeetingScheduleInfo, date: NaiveDate) -> bool {
|
||||
let weekday = chrono_weekday_to_day_of_week(date.weekday());
|
||||
let meeting_days = meeting_time.days_of_week();
|
||||
|
||||
meeting_days.contains(&weekday)
|
||||
}
|
||||
|
||||
/// Get holiday dates that fall within the course date range and would conflict with class meetings
|
||||
fn get_holiday_exceptions(meeting_time: &MeetingScheduleInfo) -> Vec<NaiveDate> {
|
||||
let mut exceptions = Vec::new();
|
||||
|
||||
// Get the year range from the course date range
|
||||
let start_year = meeting_time.date_range.start.year();
|
||||
let end_year = meeting_time.date_range.end.year();
|
||||
|
||||
for (_, holiday) in UNIVERSITY_HOLIDAYS {
|
||||
// Check for the holiday in each year of the course
|
||||
for year in start_year..=end_year {
|
||||
let holiday_dates = holiday.get_dates_for_year(year);
|
||||
|
||||
for holiday_date in holiday_dates {
|
||||
// Check if the holiday falls within the course date range
|
||||
if holiday_date >= meeting_time.date_range.start
|
||||
&& holiday_date <= meeting_time.date_range.end
|
||||
{
|
||||
// Check if the class would actually meet on this day
|
||||
if class_meets_on_date(meeting_time, holiday_date) {
|
||||
exceptions.push(holiday_date);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
exceptions
|
||||
}
|
||||
|
||||
/// Generate EXDATE property for holiday exceptions
|
||||
fn generate_exdate_property(
|
||||
exceptions: &[NaiveDate],
|
||||
start_time: chrono::DateTime<Utc>,
|
||||
) -> Option<String> {
|
||||
if exceptions.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut exdate_values = Vec::new();
|
||||
|
||||
for &exception_date in exceptions {
|
||||
// Create a datetime for the exception using the same time as the start time
|
||||
let exception_datetime = exception_date.and_time(start_time.time()).and_utc();
|
||||
|
||||
let exdate_str = exception_datetime.format("%Y%m%dT%H%M%SZ").to_string();
|
||||
exdate_values.push(exdate_str);
|
||||
}
|
||||
|
||||
Some(format!("EXDATE:{}", exdate_values.join(",")))
|
||||
}
|
||||
|
||||
/// Escape text for ICS format
|
||||
fn escape_ics_text(text: &str) -> String {
|
||||
text.replace("\\", "\\\\")
|
||||
.replace(";", "\\;")
|
||||
.replace(",", "\\,")
|
||||
.replace("\n", "\\n")
|
||||
.replace("\r", "")
|
||||
}
|
||||
|
||||
@@ -25,8 +25,6 @@ pub struct Config {
|
||||
pub port: u16,
|
||||
/// Database connection URL
|
||||
pub database_url: String,
|
||||
/// Redis connection URL
|
||||
pub redis_url: String,
|
||||
/// Graceful shutdown timeout duration
|
||||
///
|
||||
/// Accepts both numeric values (seconds) and duration strings
|
||||
|
||||
51
src/main.rs
51
src/main.rs
@@ -1,4 +1,6 @@
|
||||
use serenity::all::{ClientBuilder, GatewayIntents};
|
||||
use figment::value::UncasedStr;
|
||||
use num_format::{Locale, ToFormattedString};
|
||||
use serenity::all::{ActivityData, ClientBuilder, GatewayIntents};
|
||||
use tokio::signal;
|
||||
use tracing::{error, info, warn};
|
||||
use tracing_subscriber::{EnvFilter, FmtSubscriber};
|
||||
@@ -26,13 +28,34 @@ mod services;
|
||||
mod state;
|
||||
mod web;
|
||||
|
||||
async fn update_bot_status(
|
||||
ctx: &serenity::all::Context,
|
||||
app_state: &AppState,
|
||||
) -> Result<(), anyhow::Error> {
|
||||
let course_count = app_state.get_course_count().await?;
|
||||
|
||||
ctx.set_activity(Some(ActivityData::playing(format!(
|
||||
"Querying {:} classes",
|
||||
course_count.to_formatted_string(&Locale::en)
|
||||
))));
|
||||
|
||||
tracing::info!(course_count = course_count, "Updated bot status");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
dotenvy::dotenv().ok();
|
||||
|
||||
// Load configuration first to get log level
|
||||
let config: Config = Figment::new()
|
||||
.merge(Env::raw())
|
||||
.merge(Env::raw().map(|k| {
|
||||
if k == UncasedStr::new("RAILWAY_DEPLOYMENT_DRAINING_SECONDS") {
|
||||
"SHUTDOWN_TIMEOUT".into()
|
||||
} else {
|
||||
k.into()
|
||||
}
|
||||
}))
|
||||
.extract()
|
||||
.expect("Failed to load config");
|
||||
|
||||
@@ -95,8 +118,7 @@ async fn main() {
|
||||
.expect("Failed to create BannerApi");
|
||||
|
||||
let banner_api_arc = Arc::new(banner_api);
|
||||
let app_state = AppState::new(banner_api_arc.clone(), &config.redis_url)
|
||||
.expect("Failed to create AppState");
|
||||
let app_state = AppState::new(banner_api_arc.clone(), db_pool.clone());
|
||||
|
||||
// Create BannerState for web service
|
||||
let banner_state = BannerState {
|
||||
@@ -167,6 +189,27 @@ async fn main() {
|
||||
)
|
||||
.await?;
|
||||
poise::builtins::register_globally(ctx, &framework.options().commands).await?;
|
||||
|
||||
// Start status update task
|
||||
let status_app_state = app_state.clone();
|
||||
let status_ctx = ctx.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut interval = tokio::time::interval(std::time::Duration::from_secs(30));
|
||||
|
||||
// Update status immediately on startup
|
||||
if let Err(e) = update_bot_status(&status_ctx, &status_app_state).await {
|
||||
tracing::error!(error = %e, "Failed to update status on startup");
|
||||
}
|
||||
|
||||
loop {
|
||||
interval.tick().await;
|
||||
|
||||
if let Err(e) = update_bot_status(&status_ctx, &status_app_state).await {
|
||||
tracing::error!(error = %e, "Failed to update bot status");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Ok(Data { app_state })
|
||||
})
|
||||
})
|
||||
|
||||
112
src/scraper/jobs/mod.rs
Normal file
112
src/scraper/jobs/mod.rs
Normal file
@@ -0,0 +1,112 @@
|
||||
pub mod subject;
|
||||
|
||||
use crate::banner::BannerApi;
|
||||
use crate::data::models::TargetType;
|
||||
use crate::error::Result;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::PgPool;
|
||||
use std::fmt;
|
||||
|
||||
/// Errors that can occur during job parsing
|
||||
#[derive(Debug)]
|
||||
pub enum JobParseError {
|
||||
InvalidJson(serde_json::Error),
|
||||
UnsupportedTargetType(TargetType),
|
||||
MissingRequiredField(String),
|
||||
}
|
||||
|
||||
impl fmt::Display for JobParseError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
JobParseError::InvalidJson(e) => write!(f, "Invalid JSON in job payload: {}", e),
|
||||
JobParseError::UnsupportedTargetType(t) => {
|
||||
write!(f, "Unsupported target type: {:?}", t)
|
||||
}
|
||||
JobParseError::MissingRequiredField(field) => {
|
||||
write!(f, "Missing required field: {}", field)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for JobParseError {
|
||||
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
|
||||
match self {
|
||||
JobParseError::InvalidJson(e) => Some(e),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Errors that can occur during job processing
|
||||
#[derive(Debug)]
|
||||
pub enum JobError {
|
||||
Recoverable(anyhow::Error), // API failures, network issues
|
||||
Unrecoverable(anyhow::Error), // Parse errors, corrupted data
|
||||
}
|
||||
|
||||
impl fmt::Display for JobError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
JobError::Recoverable(e) => write!(f, "Recoverable error: {}", e),
|
||||
JobError::Unrecoverable(e) => write!(f, "Unrecoverable error: {}", e),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for JobError {
|
||||
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
|
||||
match self {
|
||||
JobError::Recoverable(e) => e.source(),
|
||||
JobError::Unrecoverable(e) => e.source(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Common trait interface for all job types
|
||||
#[async_trait::async_trait]
|
||||
pub trait Job: Send + Sync {
|
||||
/// The target type this job handles
|
||||
fn target_type(&self) -> TargetType;
|
||||
|
||||
/// Process the job with the given API client and database pool
|
||||
async fn process(&self, banner_api: &BannerApi, db_pool: &PgPool) -> Result<()>;
|
||||
|
||||
/// Get a human-readable description of the job
|
||||
fn description(&self) -> String;
|
||||
}
|
||||
|
||||
/// Main job enum that dispatches to specific job implementations
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum JobType {
|
||||
Subject(subject::SubjectJob),
|
||||
}
|
||||
|
||||
impl JobType {
|
||||
/// Create a job from the target type and payload
|
||||
pub fn from_target_type_and_payload(
|
||||
target_type: TargetType,
|
||||
payload: serde_json::Value,
|
||||
) -> Result<Self, JobParseError> {
|
||||
match target_type {
|
||||
TargetType::Subject => {
|
||||
let subject_job: subject::SubjectJob =
|
||||
serde_json::from_value(payload).map_err(JobParseError::InvalidJson)?;
|
||||
Ok(JobType::Subject(subject_job))
|
||||
}
|
||||
_ => Err(JobParseError::UnsupportedTargetType(target_type)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert to a Job trait object
|
||||
pub fn as_job(self) -> Box<dyn Job> {
|
||||
match self {
|
||||
JobType::Subject(job) => Box::new(job),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper function to create a subject job
|
||||
pub fn create_subject_job(subject: String) -> JobType {
|
||||
JobType::Subject(subject::SubjectJob::new(subject))
|
||||
}
|
||||
93
src/scraper/jobs/subject.rs
Normal file
93
src/scraper/jobs/subject.rs
Normal file
@@ -0,0 +1,93 @@
|
||||
use super::Job;
|
||||
use crate::banner::{BannerApi, Course, SearchQuery, Term};
|
||||
use crate::data::models::TargetType;
|
||||
use crate::error::Result;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::PgPool;
|
||||
use tracing::{debug, info, trace};
|
||||
|
||||
/// Job implementation for scraping subject data
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SubjectJob {
|
||||
pub subject: String,
|
||||
}
|
||||
|
||||
impl SubjectJob {
|
||||
pub fn new(subject: String) -> Self {
|
||||
Self { subject }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl Job for SubjectJob {
|
||||
fn target_type(&self) -> TargetType {
|
||||
TargetType::Subject
|
||||
}
|
||||
|
||||
async fn process(&self, banner_api: &BannerApi, db_pool: &PgPool) -> Result<()> {
|
||||
let subject_code = &self.subject;
|
||||
debug!(subject = subject_code, "Processing subject job");
|
||||
|
||||
// Get the current term
|
||||
let term = Term::get_current().inner().to_string();
|
||||
let query = SearchQuery::new().subject(subject_code).max_results(500);
|
||||
|
||||
let search_result = banner_api
|
||||
.search(&term, &query, "subjectDescription", false)
|
||||
.await?;
|
||||
|
||||
if let Some(courses_from_api) = search_result.data {
|
||||
info!(
|
||||
subject = subject_code,
|
||||
count = courses_from_api.len(),
|
||||
"Found courses"
|
||||
);
|
||||
for course in courses_from_api {
|
||||
self.upsert_course(&course, db_pool).await?;
|
||||
}
|
||||
}
|
||||
|
||||
debug!(subject = subject_code, "Subject job completed");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn description(&self) -> String {
|
||||
format!("Scrape subject: {}", self.subject)
|
||||
}
|
||||
}
|
||||
|
||||
impl SubjectJob {
|
||||
async fn upsert_course(&self, course: &Course, db_pool: &PgPool) -> Result<()> {
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO courses (crn, subject, course_number, title, term_code, enrollment, max_enrollment, wait_count, wait_capacity, last_scraped_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||
ON CONFLICT (crn, term_code) DO UPDATE SET
|
||||
subject = EXCLUDED.subject,
|
||||
course_number = EXCLUDED.course_number,
|
||||
title = EXCLUDED.title,
|
||||
enrollment = EXCLUDED.enrollment,
|
||||
max_enrollment = EXCLUDED.max_enrollment,
|
||||
wait_count = EXCLUDED.wait_count,
|
||||
wait_capacity = EXCLUDED.wait_capacity,
|
||||
last_scraped_at = EXCLUDED.last_scraped_at
|
||||
"#,
|
||||
)
|
||||
.bind(&course.course_reference_number)
|
||||
.bind(&course.subject)
|
||||
.bind(&course.course_number)
|
||||
.bind(&course.course_title)
|
||||
.bind(&course.term)
|
||||
.bind(course.enrollment)
|
||||
.bind(course.maximum_enrollment)
|
||||
.bind(course.wait_count)
|
||||
.bind(course.wait_capacity)
|
||||
.bind(chrono::Utc::now())
|
||||
.execute(db_pool)
|
||||
.await
|
||||
.map(|result| {
|
||||
trace!(subject = course.subject, crn = course.course_reference_number, result = ?result, "Course upserted");
|
||||
})
|
||||
.map_err(|e| anyhow::anyhow!("Failed to upsert course: {e}"))
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod jobs;
|
||||
pub mod scheduler;
|
||||
pub mod worker;
|
||||
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
use crate::banner::{BannerApi, Term};
|
||||
use crate::data::models::{ScrapePriority, TargetType};
|
||||
use crate::error::Result;
|
||||
use crate::scraper::jobs::subject::SubjectJob;
|
||||
use serde_json::json;
|
||||
use sqlx::PgPool;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::time;
|
||||
use tracing::{error, info, debug, trace};
|
||||
use tracing::{debug, error, info, trace};
|
||||
|
||||
/// Periodically analyzes data and enqueues prioritized scrape jobs.
|
||||
pub struct Scheduler {
|
||||
@@ -40,42 +41,77 @@ impl Scheduler {
|
||||
async fn schedule_jobs(&self) -> Result<()> {
|
||||
// For now, we will implement a simple baseline scheduling strategy:
|
||||
// 1. Get a list of all subjects from the Banner API.
|
||||
// 2. For each subject, check if an active (not locked, not completed) job already exists.
|
||||
// 3. If no job exists, create a new, low-priority job to be executed in the near future.
|
||||
// 2. Query existing jobs for all subjects in a single query.
|
||||
// 3. Create new jobs only for subjects that don't have existing jobs.
|
||||
let term = Term::get_current().inner().to_string();
|
||||
|
||||
debug!(term = term, "Enqueuing subject jobs");
|
||||
|
||||
let subjects = self.banner_api.get_subjects("", &term, 1, 500).await?;
|
||||
debug!(subject_count = subjects.len(), "Retrieved subjects from API");
|
||||
debug!(
|
||||
subject_count = subjects.len(),
|
||||
"Retrieved subjects from API"
|
||||
);
|
||||
|
||||
for subject in subjects {
|
||||
let payload = json!({ "subject": subject.code });
|
||||
// Create payloads for all subjects
|
||||
let subject_payloads: Vec<_> = subjects
|
||||
.iter()
|
||||
.map(|subject| json!({ "subject": subject.code }))
|
||||
.collect();
|
||||
|
||||
let existing_job: Option<(i32,)> = sqlx::query_as(
|
||||
"SELECT id FROM scrape_jobs WHERE target_type = $1 AND target_payload = $2 AND locked_at IS NULL"
|
||||
)
|
||||
.bind(TargetType::Subject)
|
||||
.bind(&payload)
|
||||
.fetch_optional(&self.db_pool)
|
||||
.await?;
|
||||
// Query existing jobs for all subjects in a single query
|
||||
let existing_jobs: Vec<(serde_json::Value,)> = sqlx::query_as(
|
||||
"SELECT target_payload FROM scrape_jobs
|
||||
WHERE target_type = $1 AND target_payload = ANY($2) AND locked_at IS NULL",
|
||||
)
|
||||
.bind(TargetType::Subject)
|
||||
.bind(&subject_payloads)
|
||||
.fetch_all(&self.db_pool)
|
||||
.await?;
|
||||
|
||||
if existing_job.is_some() {
|
||||
trace!(subject = subject.code, "Job already exists, skipping");
|
||||
continue;
|
||||
// Convert to a HashSet for efficient lookup
|
||||
let existing_payloads: std::collections::HashSet<String> = existing_jobs
|
||||
.into_iter()
|
||||
.map(|(payload,)| payload.to_string())
|
||||
.collect();
|
||||
|
||||
// Filter out subjects that already have jobs and prepare new jobs
|
||||
let new_jobs: Vec<_> = subjects
|
||||
.into_iter()
|
||||
.filter_map(|subject| {
|
||||
let job = SubjectJob::new(subject.code.clone());
|
||||
let payload = serde_json::to_value(&job).unwrap();
|
||||
let payload_str = payload.to_string();
|
||||
|
||||
if existing_payloads.contains(&payload_str) {
|
||||
trace!(subject = subject.code, "Job already exists, skipping");
|
||||
None
|
||||
} else {
|
||||
Some((payload, subject.code))
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Insert all new jobs in a single batch
|
||||
if !new_jobs.is_empty() {
|
||||
let now = chrono::Utc::now();
|
||||
let mut tx = self.db_pool.begin().await?;
|
||||
|
||||
for (payload, subject_code) in new_jobs {
|
||||
sqlx::query(
|
||||
"INSERT INTO scrape_jobs (target_type, target_payload, priority, execute_at) VALUES ($1, $2, $3, $4)"
|
||||
)
|
||||
.bind(TargetType::Subject)
|
||||
.bind(&payload)
|
||||
.bind(ScrapePriority::Low)
|
||||
.bind(now)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
debug!(subject = subject_code, "New job enqueued for subject");
|
||||
}
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO scrape_jobs (target_type, target_payload, priority, execute_at) VALUES ($1, $2, $3, $4)"
|
||||
)
|
||||
.bind(TargetType::Subject)
|
||||
.bind(&payload)
|
||||
.bind(ScrapePriority::Low)
|
||||
.bind(chrono::Utc::now())
|
||||
.execute(&self.db_pool)
|
||||
.await?;
|
||||
|
||||
debug!(subject = subject.code, "New job enqueued for subject");
|
||||
tx.commit().await?;
|
||||
}
|
||||
|
||||
debug!("Job scheduling complete");
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use crate::banner::{BannerApi, BannerApiError, Course, SearchQuery, Term};
|
||||
use crate::banner::{BannerApi, BannerApiError};
|
||||
use crate::data::models::ScrapeJob;
|
||||
use crate::error::Result;
|
||||
use serde_json::Value;
|
||||
use crate::scraper::jobs::{JobError, JobType};
|
||||
use sqlx::PgPool;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
@@ -35,38 +35,58 @@ impl Worker {
|
||||
Ok(Some(job)) => {
|
||||
let job_id = job.id;
|
||||
debug!(worker_id = self.id, job_id = job.id, "Processing job");
|
||||
if let Err(e) = self.process_job(job).await {
|
||||
// Check if the error is due to an invalid session
|
||||
if let Some(BannerApiError::InvalidSession(_)) =
|
||||
e.downcast_ref::<BannerApiError>()
|
||||
{
|
||||
warn!(
|
||||
worker_id = self.id,
|
||||
job_id, "Invalid session detected. Forcing session refresh."
|
||||
);
|
||||
} else {
|
||||
error!(worker_id = self.id, job_id, error = ?e, "Failed to process job");
|
||||
match self.process_job(job).await {
|
||||
Ok(()) => {
|
||||
debug!(worker_id = self.id, job_id, "Job completed");
|
||||
// If successful, delete the job.
|
||||
if let Err(delete_err) = self.delete_job(job_id).await {
|
||||
error!(
|
||||
worker_id = self.id,
|
||||
job_id,
|
||||
?delete_err,
|
||||
"Failed to delete job"
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(JobError::Recoverable(e)) => {
|
||||
// Check if the error is due to an invalid session
|
||||
if let Some(BannerApiError::InvalidSession(_)) =
|
||||
e.downcast_ref::<BannerApiError>()
|
||||
{
|
||||
warn!(
|
||||
worker_id = self.id,
|
||||
job_id, "Invalid session detected. Forcing session refresh."
|
||||
);
|
||||
} else {
|
||||
error!(worker_id = self.id, job_id, error = ?e, "Failed to process job");
|
||||
}
|
||||
|
||||
// Unlock the job so it can be retried
|
||||
if let Err(unlock_err) = self.unlock_job(job_id).await {
|
||||
error!(
|
||||
worker_id = self.id,
|
||||
job_id,
|
||||
?unlock_err,
|
||||
"Failed to unlock job"
|
||||
);
|
||||
// Unlock the job so it can be retried
|
||||
if let Err(unlock_err) = self.unlock_job(job_id).await {
|
||||
error!(
|
||||
worker_id = self.id,
|
||||
job_id,
|
||||
?unlock_err,
|
||||
"Failed to unlock job"
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
debug!(worker_id = self.id, job_id, "Job completed");
|
||||
// If successful, delete the job.
|
||||
if let Err(delete_err) = self.delete_job(job_id).await {
|
||||
Err(JobError::Unrecoverable(e)) => {
|
||||
error!(
|
||||
worker_id = self.id,
|
||||
job_id,
|
||||
?delete_err,
|
||||
"Failed to delete job"
|
||||
error = ?e,
|
||||
"Job corrupted, deleting"
|
||||
);
|
||||
// Parse errors are unrecoverable - delete the job
|
||||
if let Err(delete_err) = self.delete_job(job_id).await {
|
||||
error!(
|
||||
worker_id = self.id,
|
||||
job_id,
|
||||
?delete_err,
|
||||
"Failed to delete corrupted job"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -109,79 +129,26 @@ impl Worker {
|
||||
Ok(job)
|
||||
}
|
||||
|
||||
async fn process_job(&self, job: ScrapeJob) -> Result<()> {
|
||||
match job.target_type {
|
||||
crate::data::models::TargetType::Subject => {
|
||||
self.process_subject_job(&job.target_payload).await
|
||||
}
|
||||
_ => {
|
||||
warn!(worker_id = self.id, job_id = job.id, "unhandled job type");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
async fn process_job(&self, job: ScrapeJob) -> Result<(), JobError> {
|
||||
// Convert the database job to our job type
|
||||
let job_type = JobType::from_target_type_and_payload(job.target_type, job.target_payload)
|
||||
.map_err(|e| JobError::Unrecoverable(anyhow::anyhow!(e)))?; // Parse errors are unrecoverable
|
||||
|
||||
async fn process_subject_job(&self, payload: &Value) -> Result<()> {
|
||||
let subject_code = payload["subject"]
|
||||
.as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("Invalid subject payload"))?;
|
||||
info!(
|
||||
// Get the job implementation
|
||||
let job_impl = job_type.as_job();
|
||||
|
||||
debug!(
|
||||
worker_id = self.id,
|
||||
subject = subject_code,
|
||||
"Scraping subject"
|
||||
job_id = job.id,
|
||||
description = job_impl.description(),
|
||||
"Processing job"
|
||||
);
|
||||
|
||||
let term = Term::get_current().inner().to_string();
|
||||
let query = SearchQuery::new().subject(subject_code).max_results(500);
|
||||
|
||||
let search_result = self
|
||||
.banner_api
|
||||
.search(&term, &query, "subjectDescription", false)
|
||||
.await?;
|
||||
|
||||
if let Some(courses_from_api) = search_result.data {
|
||||
info!(
|
||||
worker_id = self.id,
|
||||
subject = subject_code,
|
||||
count = courses_from_api.len(),
|
||||
"Found courses"
|
||||
);
|
||||
for course in courses_from_api {
|
||||
self.upsert_course(&course).await?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn upsert_course(&self, course: &Course) -> Result<()> {
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO courses (crn, subject, course_number, title, term_code, enrollment, max_enrollment, wait_count, wait_capacity, last_scraped_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||
ON CONFLICT (crn, term_code) DO UPDATE SET
|
||||
subject = EXCLUDED.subject,
|
||||
course_number = EXCLUDED.course_number,
|
||||
title = EXCLUDED.title,
|
||||
enrollment = EXCLUDED.enrollment,
|
||||
max_enrollment = EXCLUDED.max_enrollment,
|
||||
wait_count = EXCLUDED.wait_count,
|
||||
wait_capacity = EXCLUDED.wait_capacity,
|
||||
last_scraped_at = EXCLUDED.last_scraped_at
|
||||
"#,
|
||||
)
|
||||
.bind(&course.course_reference_number)
|
||||
.bind(&course.subject)
|
||||
.bind(&course.course_number)
|
||||
.bind(&course.course_title)
|
||||
.bind(&course.term)
|
||||
.bind(course.enrollment)
|
||||
.bind(course.maximum_enrollment)
|
||||
.bind(course.wait_count)
|
||||
.bind(course.wait_capacity)
|
||||
.bind(chrono::Utc::now())
|
||||
.execute(&self.db_pool)
|
||||
.await?;
|
||||
// Process the job - API errors are recoverable
|
||||
job_impl
|
||||
.process(&self.banner_api, &self.db_pool)
|
||||
.await
|
||||
.map_err(JobError::Recoverable)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
46
src/state.rs
46
src/state.rs
@@ -3,46 +3,36 @@
|
||||
use crate::banner::BannerApi;
|
||||
use crate::banner::Course;
|
||||
use anyhow::Result;
|
||||
use redis::AsyncCommands;
|
||||
use redis::Client;
|
||||
use sqlx::PgPool;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
pub banner_api: Arc<BannerApi>,
|
||||
pub redis: Arc<Client>,
|
||||
pub db_pool: PgPool,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
pub fn new(
|
||||
banner_api: Arc<BannerApi>,
|
||||
redis_url: &str,
|
||||
) -> Result<Self, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let redis_client = Client::open(redis_url)?;
|
||||
|
||||
Ok(Self {
|
||||
pub fn new(banner_api: Arc<BannerApi>, db_pool: PgPool) -> Self {
|
||||
Self {
|
||||
banner_api,
|
||||
redis: Arc::new(redis_client),
|
||||
})
|
||||
db_pool,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get a course by CRN with Redis cache fallback to Banner API
|
||||
/// Get a course by CRN directly from 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?;
|
||||
self.banner_api
|
||||
.get_course_by_crn(term, crn)
|
||||
.await?
|
||||
.ok_or_else(|| anyhow::anyhow!("Course not found for CRN {crn}"))
|
||||
}
|
||||
|
||||
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}"))
|
||||
/// Get the total number of courses in the database
|
||||
pub async fn get_course_count(&self) -> Result<i64> {
|
||||
let count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM courses")
|
||||
.fetch_one(&self.db_pool)
|
||||
.await?;
|
||||
Ok(count.0)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,18 +69,8 @@ async fn status(State(_state): State<BannerState>) -> Json<Value> {
|
||||
async fn metrics(State(_state): State<BannerState>) -> Json<Value> {
|
||||
// For now, return basic metrics structure
|
||||
Json(json!({
|
||||
"redis": {
|
||||
"status": "connected",
|
||||
"connected_clients": "TODO: implement client counting",
|
||||
"used_memory": "TODO: implement memory tracking"
|
||||
},
|
||||
"cache": {
|
||||
"courses": {
|
||||
"count": "TODO: implement course counting"
|
||||
},
|
||||
"subjects": {
|
||||
"count": "TODO: implement subject counting"
|
||||
}
|
||||
"banner_api": {
|
||||
"status": "connected"
|
||||
},
|
||||
"timestamp": chrono::Utc::now().to_rfc3339()
|
||||
}))
|
||||
|
||||
Reference in New Issue
Block a user