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 = 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        // TODO: Handle error if no info found for single course code
95        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                // Select link that has already started and has the latest end date.
106                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                        // Only update link if there is a new one.
118                        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}