mirror of
https://github.com/Xevion/banner.git
synced 2026-01-31 12:23:33 -06:00
feat: add calendar export endpoints for ICS and Google Calendar
This commit is contained in:
@@ -0,0 +1,136 @@
|
||||
//! Web API endpoints for calendar export (ICS download + Google Calendar redirect).
|
||||
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
http::{StatusCode, header},
|
||||
response::{IntoResponse, Redirect, Response},
|
||||
};
|
||||
|
||||
use crate::calendar::{CalendarCourse, generate_gcal_url, generate_ics};
|
||||
use crate::data::models::DbMeetingTime;
|
||||
use crate::state::AppState;
|
||||
|
||||
/// Fetch course + meeting times, build a `CalendarCourse`.
|
||||
async fn load_calendar_course(
|
||||
state: &AppState,
|
||||
term: &str,
|
||||
crn: &str,
|
||||
) -> Result<(CalendarCourse, Vec<DbMeetingTime>), (StatusCode, String)> {
|
||||
let course = crate::data::courses::get_course_by_crn(&state.db_pool, crn, term)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(error = %e, "Calendar: course lookup failed");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"Lookup failed".to_string(),
|
||||
)
|
||||
})?
|
||||
.ok_or_else(|| (StatusCode::NOT_FOUND, "Course not found".to_string()))?;
|
||||
|
||||
let instructors = crate::data::courses::get_course_instructors(&state.db_pool, course.id)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
let primary_instructor = instructors
|
||||
.iter()
|
||||
.find(|i| i.is_primary)
|
||||
.or(instructors.first())
|
||||
.map(|i| i.display_name.clone());
|
||||
|
||||
let meeting_times: Vec<DbMeetingTime> =
|
||||
serde_json::from_value(course.meeting_times.clone()).unwrap_or_default();
|
||||
|
||||
let cal_course = CalendarCourse {
|
||||
crn: course.crn.clone(),
|
||||
subject: course.subject.clone(),
|
||||
course_number: course.course_number.clone(),
|
||||
title: course.title.clone(),
|
||||
sequence_number: course.sequence_number.clone(),
|
||||
primary_instructor,
|
||||
};
|
||||
|
||||
Ok((cal_course, meeting_times))
|
||||
}
|
||||
|
||||
/// `GET /api/courses/{term}/{crn}/calendar.ics`
|
||||
///
|
||||
/// Returns an ICS file download for the course.
|
||||
pub async fn course_ics(
|
||||
State(state): State<AppState>,
|
||||
Path((term, crn)): Path<(String, String)>,
|
||||
) -> Result<Response, (StatusCode, String)> {
|
||||
let (cal_course, meeting_times) = load_calendar_course(&state, &term, &crn).await?;
|
||||
|
||||
if meeting_times.is_empty() {
|
||||
return Err((
|
||||
StatusCode::NOT_FOUND,
|
||||
"No meeting times found for this course".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let result = generate_ics(&cal_course, &meeting_times).map_err(|e| {
|
||||
tracing::error!(error = %e, "ICS generation failed");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"Failed to generate ICS file".to_string(),
|
||||
)
|
||||
})?;
|
||||
|
||||
let response = (
|
||||
[
|
||||
(header::CONTENT_TYPE, "text/calendar; charset=utf-8"),
|
||||
(
|
||||
header::CONTENT_DISPOSITION,
|
||||
&format!("attachment; filename=\"{}\"", result.filename),
|
||||
),
|
||||
(header::CACHE_CONTROL, "no-cache"),
|
||||
],
|
||||
result.content,
|
||||
)
|
||||
.into_response();
|
||||
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
/// `GET /api/courses/{term}/{crn}/gcal`
|
||||
///
|
||||
/// Redirects to Google Calendar with a pre-filled event for the first meeting time.
|
||||
/// If multiple meeting times exist, uses the first one with scheduled days/times.
|
||||
pub async fn course_gcal(
|
||||
State(state): State<AppState>,
|
||||
Path((term, crn)): Path<(String, String)>,
|
||||
) -> Result<Response, (StatusCode, String)> {
|
||||
let (cal_course, meeting_times) = load_calendar_course(&state, &term, &crn).await?;
|
||||
|
||||
if meeting_times.is_empty() {
|
||||
return Err((
|
||||
StatusCode::NOT_FOUND,
|
||||
"No meeting times found for this course".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
// Prefer the first meeting time that has actual days/times scheduled
|
||||
let mt = meeting_times
|
||||
.iter()
|
||||
.find(|mt| {
|
||||
mt.begin_time.is_some()
|
||||
&& (mt.monday
|
||||
|| mt.tuesday
|
||||
|| mt.wednesday
|
||||
|| mt.thursday
|
||||
|| mt.friday
|
||||
|| mt.saturday
|
||||
|| mt.sunday)
|
||||
})
|
||||
.unwrap_or(&meeting_times[0]);
|
||||
|
||||
let url = generate_gcal_url(&cal_course, mt).map_err(|e| {
|
||||
tracing::error!(error = %e, "Google Calendar URL generation failed");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"Failed to generate Google Calendar URL".to_string(),
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(Redirect::temporary(&url).into_response())
|
||||
}
|
||||
@@ -6,6 +6,7 @@ pub mod admin_scraper;
|
||||
#[cfg(feature = "embed-assets")]
|
||||
pub mod assets;
|
||||
pub mod auth;
|
||||
pub mod calendar;
|
||||
#[cfg(feature = "embed-assets")]
|
||||
pub mod encoding;
|
||||
pub mod extractors;
|
||||
|
||||
@@ -13,6 +13,7 @@ use crate::web::admin;
|
||||
use crate::web::admin_rmp;
|
||||
use crate::web::admin_scraper;
|
||||
use crate::web::auth::{self, AuthConfig};
|
||||
use crate::web::calendar;
|
||||
use crate::web::ws;
|
||||
#[cfg(feature = "embed-assets")]
|
||||
use axum::{
|
||||
@@ -45,6 +46,11 @@ pub fn create_router(app_state: AppState, auth_config: AuthConfig) -> Router {
|
||||
.route("/metrics", get(metrics))
|
||||
.route("/courses/search", get(search_courses))
|
||||
.route("/courses/{term}/{crn}", get(get_course))
|
||||
.route(
|
||||
"/courses/{term}/{crn}/calendar.ics",
|
||||
get(calendar::course_ics),
|
||||
)
|
||||
.route("/courses/{term}/{crn}/gcal", get(calendar::course_gcal))
|
||||
.route("/terms", get(get_terms))
|
||||
.route("/subjects", get(get_subjects))
|
||||
.route("/reference/{category}", get(get_reference))
|
||||
|
||||
Reference in New Issue
Block a user