headless_lms_server/programs/
open_university_registration_link_fetcher.rs1use std::env;
2
3use crate::setup_tracing;
4use anyhow::Context;
5use chrono::{NaiveDateTime, Utc};
6use dotenv::dotenv;
7use headless_lms_models as models;
8use reqwest::Client;
9use serde::{Deserialize, Serialize};
10use sqlx::{PgConnection, PgPool};
11
12const OPEN_UNIVERSITY_REGISTRATION_BASE_URL: &str =
13 "https://www.avoin.helsinki.fi/palvelut/esittely.aspx?s=";
14const OPEN_UNIVERSITY_COURSE_URL: &str = "OPEN_UNIVERSITY_COURSE_URL";
15const OPEN_UNIVERSITY_TOKEN: &str = "OPEN_UNIVERSITY_TOKEN";
16
17pub async fn main() -> anyhow::Result<()> {
18 if env::var("RUST_LOG").is_err() {
20 unsafe { env::set_var("RUST_LOG", "info,actix_web=info,sqlx=warn") };
21 }
22 dotenv().ok();
23 setup_tracing()?;
24 let database_url = env::var("DATABASE_URL")
25 .unwrap_or_else(|_| "postgres://localhost/headless_lms_dev".to_string());
26 let open_university_course_url = env::var(OPEN_UNIVERSITY_COURSE_URL);
27 let open_university_token = env::var(OPEN_UNIVERSITY_TOKEN);
28 match (open_university_course_url, open_university_token) {
29 (Ok(url), Ok(token)) => {
30 tracing::info!("Fetching and updating Open University completion links.");
31 let db_pool = PgPool::connect(&database_url).await?;
32 let mut conn = db_pool.acquire().await?;
33 let res = fetch_and_update_completion_links(&mut conn, &url, &token).await;
34 match res {
35 Ok(updates) => {
36 tracing::info!("{} registration completion links were updated.", updates)
37 }
38 Err(err) => tracing::error!(
39 "Updating open university completion links resulted in an error: {:#?}",
40 err,
41 ),
42 };
43 }
44 _ => {
45 tracing::info!(
46 "Open university completion link fetch job was a no-op; environment values {} and {} need to be defined.",
47 OPEN_UNIVERSITY_COURSE_URL,
48 OPEN_UNIVERSITY_TOKEN,
49 );
50 }
51 }
52 Ok(())
53}
54
55async fn fetch_and_update_completion_links(
58 conn: &mut PgConnection,
59 open_university_course_url: &str,
60 open_university_token: &str,
61) -> anyhow::Result<u32> {
62 tracing::info!("Fetching new completion links from EduWeb.");
63
64 let mut updates = 0;
65 let mut errors = 0;
66 let mut skipped = 0;
67 let client = Client::default();
68 let now = Utc::now().naive_utc();
69
70 let course_codes =
71 models::course_modules::get_all_uh_course_codes_for_open_university(conn).await?;
72 tracing::info!("Found {} course codes to process", course_codes.len());
73
74 for (index, uh_course_code) in course_codes.iter().enumerate() {
75 let trimmed_code = uh_course_code.trim();
76
77 if trimmed_code.is_empty() {
78 tracing::warn!(
79 "Skipping empty course code at index {} ({}/{})",
80 index + 1,
81 index + 1,
82 course_codes.len()
83 );
84 skipped += 1;
85 continue;
86 }
87
88 tracing::debug!(
89 "Processing course code {} ({}/{})",
90 trimmed_code,
91 index + 1,
92 course_codes.len()
93 );
94
95 let url = format!("{}{}", &open_university_course_url, &trimmed_code);
96 tracing::debug!("Fetching data from URL: {}", url);
97
98 let infos =
100 get_open_university_info_for_course_code(&client, &url, open_university_token).await;
101 match infos {
102 Ok(infos) => {
103 tracing::debug!(
104 "Retrieved {} registration alternatives for course code {}",
105 infos.len(),
106 trimmed_code
107 );
108
109 let best_candidate = select_best_candidate(now, infos.clone());
111 match best_candidate {
112 Some(open_university_info) => {
113 tracing::debug!(
114 "Selected best candidate for course {}: start_date={}, end_date={}, link={}",
115 trimmed_code,
116 open_university_info.start_date,
117 open_university_info.end_date,
118 open_university_info.link
119 );
120
121 let res = update_course_registration_link(
123 conn,
124 trimmed_code,
125 &open_university_info,
126 )
127 .await;
128 match res {
129 Ok(_) => {
130 tracing::info!(
131 "Successfully updated registration link for course code {}",
132 trimmed_code
133 );
134 updates += 1;
135 }
136 Err(err) => {
137 tracing::error!(
138 "Failed to update link for course code {}: {:#?}",
139 trimmed_code,
140 err
141 );
142 errors += 1;
143 }
144 }
145 }
146 None => {
147 tracing::warn!(
148 "No suitable registration candidate found for course code {}",
149 trimmed_code
150 );
151
152 tracing::info!(
153 "Analyzing {} registration alternatives for course {}:",
154 infos.len(),
155 trimmed_code
156 );
157
158 let mut future_courses = 0;
159 let mut past_courses = 0;
160 let mut current_courses = 0;
161
162 for (idx, info) in infos.iter().enumerate() {
163 if info.start_date > now {
164 future_courses += 1;
165 tracing::info!(
166 " Alternative {}: FUTURE - start_date={}, end_date={}, link={} (starts in {} days)",
167 idx + 1,
168 info.start_date,
169 info.end_date,
170 info.link,
171 (info.start_date - now).num_days()
172 );
173 } else if info.end_date < now {
174 past_courses += 1;
175 tracing::info!(
176 " Alternative {}: PAST - start_date={}, end_date={}, link={} (ended {} days ago)",
177 idx + 1,
178 info.start_date,
179 info.end_date,
180 info.link,
181 (now - info.end_date).num_days()
182 );
183 } else {
184 current_courses += 1;
185 tracing::info!(
186 " Alternative {}: CURRENT - start_date={}, end_date={}, link={} (active for {} more days)",
187 idx + 1,
188 info.start_date,
189 info.end_date,
190 info.link,
191 (info.end_date - now).num_days()
192 );
193 }
194 }
195
196 tracing::info!(
197 "Course {} candidate analysis: {} total alternatives, {} future, {} past, {} current",
198 trimmed_code,
199 infos.len(),
200 future_courses,
201 past_courses,
202 current_courses
203 );
204
205 if current_courses > 0 {
206 tracing::warn!(
207 "Course {} has {} current alternatives but none were selected - this may indicate a bug in selection logic",
208 trimmed_code,
209 current_courses
210 );
211 } else if future_courses > 0 {
212 tracing::info!(
213 "Course {} has {} future alternatives but no current ones - registration may not be open yet",
214 trimmed_code,
215 future_courses
216 );
217 } else if past_courses > 0 {
218 tracing::info!(
219 "Course {} has {} past alternatives but no current ones - registration period may have ended",
220 trimmed_code,
221 past_courses
222 );
223 } else {
224 tracing::warn!(
225 "Course {} has no registration alternatives at all - this may indicate a data issue",
226 trimmed_code
227 );
228 }
229
230 skipped += 1;
231 }
232 }
233 }
234 Err(err) => {
235 tracing::error!(
236 "Failed to get completion registration info for course code '{}': {:#?}",
237 trimmed_code,
238 err
239 );
240 errors += 1;
241 }
242 }
243 }
244
245 tracing::info!(
246 "Completed Open University completion link fetch and update process. Updates: {}, Errors: {}, Skipped: {}",
247 updates,
248 errors,
249 skipped
250 );
251
252 Ok(updates)
253}
254
255#[derive(Serialize, Deserialize, Debug, Clone)]
256struct OpenUniversityInfo {
257 #[serde(rename = "oodi_id")]
258 link: String,
259 #[serde(rename = "alkupvm")]
260 start_date: NaiveDateTime,
261 #[serde(rename = "loppupvm")]
262 end_date: NaiveDateTime,
263}
264
265async fn get_open_university_info_for_course_code(
266 client: &Client,
267 course_url: &str,
268 token: &str,
269) -> anyhow::Result<Vec<OpenUniversityInfo>> {
270 let res = client
271 .get(course_url)
272 .header("Authorized", format!("Basic {}", token))
273 .send()
274 .await
275 .context("Failed to send a request to Open University.")?;
276 let alternatives: Vec<OpenUniversityInfo> = res.json().await?;
277 Ok(alternatives)
278}
279
280fn select_best_candidate(
281 now: NaiveDateTime,
282 c: Vec<OpenUniversityInfo>,
283) -> Option<OpenUniversityInfo> {
284 c.into_iter()
285 .filter(|x| x.start_date <= now)
286 .max_by(|a, b| a.end_date.cmp(&b.end_date))
287}
288
289async fn update_course_registration_link(
290 conn: &mut PgConnection,
291 uh_course_code: &str,
292 open_university_info: &OpenUniversityInfo,
293) -> anyhow::Result<()> {
294 let full_url = format!(
295 "{}{}",
296 OPEN_UNIVERSITY_REGISTRATION_BASE_URL, open_university_info.link,
297 );
298 models::open_university_registration_links::upsert(conn, uh_course_code, &full_url).await?;
299 Ok(())
300}