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    unsafe { env::set_var("RUST_LOG", "info,actix_web=info,sqlx=warn") };
20    dotenv().ok();
21    setup_tracing()?;
22    let database_url = env::var("DATABASE_URL")
23        .unwrap_or_else(|_| "postgres://localhost/headless_lms_dev".to_string());
24    let open_university_course_url = env::var(OPEN_UNIVERSITY_COURSE_URL);
25    let open_university_token = env::var(OPEN_UNIVERSITY_TOKEN);
26    match (open_university_course_url, open_university_token) {
27        (Ok(url), Ok(token)) => {
28            tracing::info!("Fetching and updating Open University completion links.");
29            let db_pool = PgPool::connect(&database_url).await?;
30            let mut conn = db_pool.acquire().await?;
31            let res = fetch_and_update_completion_links(&mut conn, &url, &token).await;
32            match res {
33                Ok(updates) => {
34                    tracing::info!("{} registration completion links were updated.", updates)
35                }
36                Err(err) => tracing::error!(
37                    "Updating open university completion links resulted in an error: {:#?}",
38                    err,
39                ),
40            };
41        }
42        _ => {
43            tracing::info!(
44                "Open university completion link fetch job was a no-op; environment values {} and {} need to be defined.",
45                OPEN_UNIVERSITY_COURSE_URL,
46                OPEN_UNIVERSITY_TOKEN,
47            );
48        }
49    }
50    Ok(())
51}
52
53/// Fetches up-to-date Open University completion registration links, upserts them to database and
54/// returns to amount of updated records.
55async fn fetch_and_update_completion_links(
56    conn: &mut PgConnection,
57    open_university_course_url: &str,
58    open_university_token: &str,
59) -> anyhow::Result<u32> {
60    let mut updates = 0;
61    let client = Client::default();
62    let now = Utc::now().naive_utc();
63    for uh_course_code in
64        models::course_modules::get_all_uh_course_codes_for_open_university(conn).await?
65    {
66        let url = format!("{}{}", &open_university_course_url, &uh_course_code);
67        // TODO: Handle error if no info found for single course code
68        let infos =
69            get_open_university_info_for_course_code(&client, &url, open_university_token).await;
70        match infos {
71            Ok(infos) => {
72                // Select link that has already started and has the latest end date.
73                let best_candidate = select_best_candidate(now, infos);
74                if let Some(open_university_info) = best_candidate {
75                    // Only update link if there is a new one.
76                    let res = update_course_registration_link(
77                        conn,
78                        &uh_course_code,
79                        &open_university_info,
80                    )
81                    .await;
82                    if res.is_err() {
83                        tracing::error!(
84                            "Failed to update link for course code {}",
85                            &uh_course_code
86                        );
87                    } else {
88                        updates += 1;
89                    }
90                }
91            }
92            _ => {
93                tracing::error!(
94                    "Failed to get completion registration info for course code '{}'.",
95                    uh_course_code,
96                );
97            }
98        }
99    }
100    Ok(updates)
101}
102
103#[derive(Serialize, Deserialize, Debug)]
104struct OpenUniversityInfo {
105    #[serde(rename = "oodi_id")]
106    link: String,
107    #[serde(rename = "alkupvm")]
108    start_date: NaiveDateTime,
109    #[serde(rename = "loppupvm")]
110    end_date: NaiveDateTime,
111}
112
113async fn get_open_university_info_for_course_code(
114    client: &Client,
115    course_url: &str,
116    token: &str,
117) -> anyhow::Result<Vec<OpenUniversityInfo>> {
118    let res = client
119        .get(course_url)
120        .header("Authorized", format!("Basic {}", token))
121        .send()
122        .await
123        .context("Failed to send a request to Open University.")?;
124    let alternatives: Vec<OpenUniversityInfo> = res.json().await?;
125    Ok(alternatives)
126}
127
128fn select_best_candidate(
129    now: NaiveDateTime,
130    c: Vec<OpenUniversityInfo>,
131) -> Option<OpenUniversityInfo> {
132    c.into_iter()
133        .filter(|x| x.start_date <= now)
134        .max_by(|a, b| a.end_date.cmp(&b.end_date))
135}
136
137async fn update_course_registration_link(
138    conn: &mut PgConnection,
139    uh_course_code: &str,
140    open_university_info: &OpenUniversityInfo,
141) -> anyhow::Result<()> {
142    let full_url = format!(
143        "{}{}",
144        OPEN_UNIVERSITY_REGISTRATION_BASE_URL, open_university_info.link,
145    );
146    models::open_university_registration_links::upsert(conn, uh_course_code, &full_url).await?;
147    Ok(())
148}