Skip to main content

headless_lms_utils/services/
sisu.rs

1use crate::{error::util_error::SisuErrorVariant, prelude::*};
2
3#[derive(Debug, Clone)]
4pub struct SisuClient {
5    base_url: String,
6}
7
8use headless_lms_base::config::bool_env_false_by_default;
9use regex::Regex;
10use serde::{Deserialize, Serialize};
11use std::sync::LazyLock;
12use std::time::Duration;
13use std::{cmp::Ordering, collections::HashMap};
14use utoipa::ToSchema;
15pub type SisuCourseInfo = Vec<SisuCourseInfoElement>;
16use url::{ParseError, Url};
17
18#[derive(Serialize, Deserialize, ToSchema, Debug)]
19#[serde(rename_all = "camelCase")]
20pub struct SisuCourseInfoElement {
21    pub id: String,
22    pub university_org_ids: Vec<String>,
23    pub group_id: String,
24    pub credits: Credits,
25    pub completion_methods: Vec<Option<serde_json::Value>>,
26    pub name: Name,
27    pub code: String,
28    pub abbreviation: Option<String>,
29    pub validity_period: SisuCourseInfoValidityPeriod,
30    pub grade_scale_id: String,
31    pub tweet_text: Option<serde_json::Value>,
32    pub outcomes: Option<Additional>,
33    pub prerequisites: Option<Additional>,
34    pub content: Option<Additional>,
35    pub additional: Option<Additional>,
36    pub learning_material: Option<Additional>,
37    pub literature: Vec<Option<serde_json::Value>>,
38    pub study_level: String,
39    pub course_unit_type: String,
40    pub subject: Option<serde_json::Value>,
41    pub cefr_level: Option<serde_json::Value>,
42    pub organisations: Vec<Organisation>,
43    pub possible_attainment_languages: Vec<String>,
44    pub part_of_degree: Option<serde_json::Value>,
45}
46
47#[derive(Serialize, Deserialize, ToSchema, Debug)]
48pub struct Additional {
49    #[serde(skip_serializing_if = "Option::is_none", default)]
50    pub fi: Option<String>,
51    #[serde(skip_serializing_if = "Option::is_none", default)]
52    pub en: Option<String>,
53    #[serde(skip_serializing_if = "Option::is_none", default)]
54    pub sv: Option<String>,
55}
56
57static STRIP_HTML_REGEX: LazyLock<Regex> =
58    LazyLock::new(|| Regex::new(r"<[^>]*>").expect("invalid regex"));
59
60impl Additional {
61    pub fn choose_language(&self, language_code: &String) -> Option<String> {
62        let mut vec = self.as_vec();
63        vec.sort_by(|o1, o2| {
64            if &o1.0 == language_code {
65                return Ordering::Less;
66            }
67            if &o2.0 == language_code {
68                return Ordering::Greater;
69            }
70            if o1.0 == "en" {
71                return Ordering::Less;
72            }
73            if o2.0 == "en" {
74                return Ordering::Greater;
75            }
76            Ordering::Equal
77        });
78
79        let max_length = vec
80            .iter()
81            .map(|n| {
82                let value = &n.1;
83                if let Some(value) = value {
84                    let cleaned = STRIP_HTML_REGEX.replace_all(value, "");
85                    cleaned.len()
86                } else {
87                    0
88                }
89            })
90            .max()
91            .unwrap_or(0);
92
93        let best = vec.iter().find(|o| {
94            let text = &o.1;
95            if let Some(text) = text {
96                let cleaned = STRIP_HTML_REGEX.replace_all(text, "");
97                let len = cleaned.len();
98                if len < max_length / 2 {
99                    return false;
100                }
101                true
102            } else {
103                false
104            }
105        });
106        best.and_then(|o| o.1.clone())
107    }
108    fn as_vec(&self) -> Vec<(String, Option<String>)> {
109        vec![
110            ("en".to_string(), self.en.clone()),
111            ("fi".to_string(), self.fi.clone()),
112            ("sv".to_string(), self.sv.clone()),
113        ]
114    }
115}
116
117#[derive(Serialize, Deserialize, ToSchema, Debug)]
118pub struct Credits {
119    pub min: Option<i64>,
120    pub max: Option<i64>,
121}
122
123#[derive(Serialize, Deserialize, ToSchema, Debug)]
124pub struct Name {
125    pub en: Option<String>,
126    pub fi: Option<String>,
127    pub sv: Option<String>,
128}
129
130#[derive(Serialize, Deserialize, ToSchema, Debug)]
131#[serde(rename_all = "camelCase")]
132pub struct Organisation {
133    pub organisation_id: Option<String>,
134    pub educational_institution_urn: Option<serde_json::Value>,
135    pub role_urn: String,
136    pub share: i64,
137    pub validity_period: Option<OrganisationValidityPeriod>,
138}
139
140#[derive(Serialize, Deserialize, ToSchema, Debug)]
141pub struct OrganisationValidityPeriod {}
142
143#[derive(Serialize, Deserialize, ToSchema, Debug)]
144#[serde(rename_all = "camelCase")]
145pub struct SisuCourseInfoValidityPeriod {
146    pub start_date: Option<String>,
147    pub end_date: Option<String>,
148}
149
150#[derive(Serialize, Deserialize, ToSchema, Debug)]
151#[serde(rename_all = "camelCase")]
152pub struct SearchResult {
153    pub id: String,
154}
155
156#[derive(Serialize, Deserialize, ToSchema, Debug)]
157#[serde(rename_all = "camelCase")]
158pub struct CourseUnitSearchResults {
159    pub search_results: Vec<SearchResult>,
160}
161#[derive(Debug, ToSchema, Serialize, Deserialize, Clone)]
162pub struct SisuDescriptions {
163    outcomes: Option<String>,
164    content: Option<String>,
165    prerequisites: Option<String>,
166    additional: Option<String>,
167    learning_material: Option<String>,
168}
169
170const TIMEOUT_DURATION: Duration = Duration::from_secs(60);
171
172impl SisuClient {
173    fn get_url(&self) -> Result<Url, ParseError> {
174        let base_url = &self.base_url;
175        let is_mock_sisu = bool_env_false_by_default("USE_MOCK_SISU_ENDPOINT");
176        if is_mock_sisu {
177            let mock_path = Url::parse(base_url.as_str())?;
178            mock_path.join("/api/v0/mock-sisu/")
179        } else {
180            Url::parse("https://sisu.helsinki.fi/kori/api/")
181        }
182    }
183
184    pub fn new(base_url: String) -> UtilResult<Self> {
185        if base_url.trim().is_empty() {
186            return Err(UtilError::new(
187                UtilErrorType::Other,
188                "BASE_URL cannot be empty".to_string(),
189                None,
190            ));
191        }
192        Ok(Self { base_url })
193    }
194
195    async fn get_request_sisu(&self, path: String) -> Result<reqwest::Response, UtilError> {
196        let base_url = Self::get_url(self)?;
197        let url = base_url.join(path.as_str())?;
198        let builder = REQWEST_CLIENT.get(url).timeout(TIMEOUT_DURATION);
199
200        builder.send().await.map_err(|e| {
201            util_err!(
202                SisuClientError(SisuErrorVariant::GenericSisuError),
203                "Request to Sisu failed",
204                e
205            )
206        })
207    }
208
209    pub async fn get_course_ids(
210        &self,
211        course_modules: Vec<String>,
212    ) -> UtilResult<Vec<Vec<String>>> {
213        let course_codes = course_modules;
214        let mut course_ids: Vec<Vec<String>> = vec![];
215        let mut invalid_codes: Vec<String> = vec![];
216        for code in course_codes {
217            let path = format!(
218                "course-unit-search?codeQuery={code}&validity=ALL&returnAllGroupVersions=true"
219            );
220            let response = self.get_request_sisu(path).await?;
221
222            if response.status().is_success() {
223                let json: CourseUnitSearchResults =
224                    serde_json::from_str(&response.text().await.unwrap_or("{}".to_string()))?;
225                let ids: Vec<String> = json.search_results.into_iter().map(|x| x.id).collect();
226
227                if ids.is_empty() {
228                    invalid_codes.push(code);
229                } else {
230                    course_ids.push(ids);
231                }
232            } else if response.status() == 404 {
233                return Err(util_err!(
234                    SisuClientError(SisuErrorVariant::SisuResourceNotFound),
235                    "Course ids not found".to_string()
236                ));
237            } else {
238                return Err(util_err!(
239                    SisuClientError(SisuErrorVariant::GenericSisuError),
240                    "Something went wrong when fetching course ids".to_string()
241                ));
242            }
243        }
244
245        if !invalid_codes.is_empty() {
246            return Err(util_err!(
247                SisuClientError(SisuErrorVariant::InvalidCourseCode),
248                format!("No data found with codes: {invalid_codes:?}")
249            ));
250        }
251        Ok(course_ids)
252    }
253
254    pub async fn get_course_info(
255        &self,
256        course_ids: Vec<Vec<String>>,
257    ) -> UtilResult<Vec<SisuCourseInfoElement>> {
258        let mut data_vec: Vec<SisuCourseInfoElement> = vec![];
259        for id in course_ids {
260            if let Some(first) = id.first() {
261                let path = format!("course-units/v1/{first}");
262                let response = self.get_request_sisu(path).await?;
263
264                if response.status().is_success() {
265                    let json: SisuCourseInfoElement =
266                        serde_json::from_str(&response.text().await.unwrap_or("{}".to_string()))?;
267                    data_vec.push(json);
268                } else if response.status() == 404 {
269                    return Err(util_err!(
270                        SisuClientError(SisuErrorVariant::SisuResourceNotFound),
271                        "Course info not found".to_string()
272                    ));
273                } else {
274                    return Err(util_err!(
275                        SisuClientError(SisuErrorVariant::GenericSisuError),
276                        "Something went wrong when fetching course info".to_string()
277                    ));
278                }
279            } else {
280                return Err(util_err!(
281                    SisuClientError(SisuErrorVariant::SisuResourceNotFound),
282                    "No courses found with course id".to_string()
283                ));
284            }
285        }
286        Ok(data_vec)
287    }
288    pub fn parse_course_info(
289        course_info: Vec<SisuCourseInfoElement>,
290        course_language: String,
291    ) -> HashMap<String, SisuDescriptions> {
292        let mut course_desc: HashMap<String, SisuDescriptions> = HashMap::new();
293
294        for module in course_info {
295            let outcome = module
296                .outcomes
297                .and_then(|x| x.choose_language(&course_language).to_owned());
298            let content = module
299                .content
300                .and_then(|x| x.choose_language(&course_language).to_owned());
301            let preq = module
302                .prerequisites
303                .and_then(|x| x.choose_language(&course_language).to_owned());
304            let material = module
305                .learning_material
306                .and_then(|x| x.choose_language(&course_language).to_owned());
307
308            let add = module
309                .additional
310                .and_then(|x| x.choose_language(&course_language).to_owned());
311
312            let descriptions = SisuDescriptions {
313                outcomes: outcome,
314                content,
315                prerequisites: preq,
316                learning_material: material,
317                additional: add,
318            };
319            course_desc.insert(module.code, descriptions);
320        }
321        course_desc
322    }
323
324    pub fn mock_for_test() -> Self {
325        Self {
326            base_url: String::from("mock-base-url"),
327        }
328    }
329}