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 =
68 models::course_modules::get_all_uh_course_codes_for_open_university(conn).await?;
69 tracing::info!("Found {} course codes to process", course_codes.len());
70
71 for (index, uh_course_code) in course_codes.iter().enumerate() {
72 let trimmed_code = uh_course_code.trim();
73
74 if trimmed_code.is_empty() {
75 tracing::warn!(
76 "Skipping empty course code at index {} ({}/{})",
77 index + 1,
78 index + 1,
79 course_codes.len()
80 );
81 skipped += 1;
82 continue;
83 }
84
85 tracing::debug!(
86 "Processing course code {} ({}/{})",
87 trimmed_code,
88 index + 1,
89 course_codes.len()
90 );
91
92 let url = format!("{}{}", &open_university_course_url, &trimmed_code);
93 tracing::debug!("Fetching data from URL: {}", url);
94
95 let infos =
97 get_open_university_info_for_course_code(&client, &url, open_university_token).await;
98 match infos {
99 Ok(infos) => {
100 tracing::debug!(
101 "Retrieved {} registration alternatives for course code {}",
102 infos.len(),
103 trimmed_code
104 );
105
106 let best_candidate = select_best_candidate(now, infos.clone());
108 match best_candidate {
109 Some(open_university_info) => {
110 tracing::debug!(
111 "Selected best candidate for course {}: start_date={}, end_date={}, link={}",
112 trimmed_code,
113 open_university_info.start_date,
114 open_university_info.end_date,
115 open_university_info.link
116 );
117
118 let res = update_course_registration_link(
120 conn,
121 trimmed_code,
122 &open_university_info,
123 )
124 .await;
125 match res {
126 Ok(_) => {
127 tracing::info!(
128 "Successfully updated registration link for course code {}",
129 trimmed_code
130 );
131 updates += 1;
132 }
133 Err(err) => {
134 tracing::error!(
135 "Failed to update link for course code {}: {:#?}",
136 trimmed_code,
137 err
138 );
139 errors += 1;
140 }
141 }
142 }
143 None => {
144 tracing::warn!(
145 "No suitable registration candidate found for course code {}",
146 trimmed_code
147 );
148
149 tracing::info!(
150 "Analyzing {} registration alternatives for course {}:",
151 infos.len(),
152 trimmed_code
153 );
154
155 let mut future_courses = 0;
156 let mut past_courses = 0;
157 let mut current_courses = 0;
158
159 for (idx, info) in infos.iter().enumerate() {
160 if info.start_date > now {
161 future_courses += 1;
162 tracing::info!(
163 " Alternative {}: FUTURE - start_date={}, end_date={}, link={} (starts in {} days)",
164 idx + 1,
165 info.start_date,
166 info.end_date,
167 info.link,
168 (info.start_date - now).num_days()
169 );
170 } else if info.end_date < now {
171 past_courses += 1;
172 tracing::info!(
173 " Alternative {}: PAST - start_date={}, end_date={}, link={} (ended {} days ago)",
174 idx + 1,
175 info.start_date,
176 info.end_date,
177 info.link,
178 (now - info.end_date).num_days()
179 );
180 } else {
181 current_courses += 1;
182 tracing::info!(
183 " Alternative {}: CURRENT - start_date={}, end_date={}, link={} (active for {} more days)",
184 idx + 1,
185 info.start_date,
186 info.end_date,
187 info.link,
188 (info.end_date - now).num_days()
189 );
190 }
191 }
192
193 tracing::info!(
194 "Course {} candidate analysis: {} total alternatives, {} future, {} past, {} current",
195 trimmed_code,
196 infos.len(),
197 future_courses,
198 past_courses,
199 current_courses
200 );
201
202 if current_courses > 0 {
203 tracing::warn!(
204 "Course {} has {} current alternatives but none were selected - this may indicate a bug in selection logic",
205 trimmed_code,
206 current_courses
207 );
208 } else if future_courses > 0 {
209 tracing::info!(
210 "Course {} has {} future alternatives but no current ones - registration may not be open yet",
211 trimmed_code,
212 future_courses
213 );
214 } else if past_courses > 0 {
215 tracing::info!(
216 "Course {} has {} past alternatives but no current ones - registration period may have ended",
217 trimmed_code,
218 past_courses
219 );
220 } else {
221 tracing::warn!(
222 "Course {} has no registration alternatives at all - this may indicate a data issue",
223 trimmed_code
224 );
225 }
226
227 skipped += 1;
228 }
229 }
230 }
231 Err(err) => {
232 tracing::error!(
233 "Failed to get completion registration info for course code '{}': {:#?}",
234 trimmed_code,
235 err
236 );
237 errors += 1;
238 }
239 }
240 }
241
242 tracing::info!(
243 "Completed Open University completion link fetch and update process. Updates: {}, Errors: {}, Skipped: {}",
244 updates,
245 errors,
246 skipped
247 );
248
249 Ok(updates)
250}
251
252#[derive(Serialize, Deserialize, Debug, Clone)]
253struct OpenUniversityInfo {
254 #[serde(rename = "oodi_id")]
255 link: String,
256 #[serde(rename = "alkupvm")]
257 start_date: NaiveDateTime,
258 #[serde(rename = "loppupvm")]
259 end_date: NaiveDateTime,
260}
261
262async fn get_open_university_info_for_course_code(
263 client: &Client,
264 course_url: &str,
265 token: &str,
266) -> anyhow::Result<Vec<OpenUniversityInfo>> {
267 let res = client
268 .get(course_url)
269 .header("Authorized", format!("Basic {}", token))
270 .send()
271 .await
272 .context("Failed to send a request to Open University.")?;
273 let alternatives: Vec<OpenUniversityInfo> = res.json().await?;
274 Ok(alternatives)
275}
276
277fn select_best_candidate(
278 now: NaiveDateTime,
279 c: Vec<OpenUniversityInfo>,
280) -> Option<OpenUniversityInfo> {
281 c.into_iter()
282 .filter(|x| x.start_date <= now)
283 .max_by(|a, b| a.end_date.cmp(&b.end_date))
284}
285
286async fn update_course_registration_link(
287 conn: &mut PgConnection,
288 uh_course_code: &str,
289 open_university_info: &OpenUniversityInfo,
290) -> anyhow::Result<()> {
291 let full_url = format!(
292 "{}{}",
293 OPEN_UNIVERSITY_REGISTRATION_BASE_URL, open_university_info.link,
294 );
295 models::open_university_registration_links::upsert(conn, uh_course_code, &full_url).await?;
296 Ok(())
297}