Skip to main content

headless_lms_server/programs/
open_university_registration_link_fetcher.rs

1use 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    // TODO: Audit that the environment access only happens in single-threaded code.
21    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
52/// Fetches up-to-date Open University completion registration links, upserts them to database and
53/// returns to amount of updated records.
54async 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        // TODO: Handle error if no info found for single course code
96        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                // Select link that has already started and has the latest end date.
107                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                        // Only update link if there is a new one.
119                        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}