headless_lms_server/programs/
open_university_registration_link_fetcher.rs

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