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