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    #[serde(default)]
46    pub responsibility_infos: Option<Vec<ResponsibilityInfo>>,
47}
48
49/// One responsible-contact entry from Sisu's course-unit API, e.g. a responsible teacher.
50#[derive(Serialize, Deserialize, ToSchema, Debug)]
51#[serde(rename_all = "camelCase")]
52pub struct ResponsibilityInfo {
53    pub role_urn: String,
54    pub person: Option<SisuPerson>,
55    pub text: Option<Additional>,
56}
57
58#[derive(Serialize, Deserialize, ToSchema, Debug)]
59#[serde(rename_all = "camelCase")]
60pub struct SisuPerson {
61    pub first_names: Option<String>,
62    pub last_name: Option<String>,
63    pub email: Option<String>,
64}
65
66/// A resolved staff contact for a course, derived from [ResponsibilityInfo].
67#[derive(Serialize, Deserialize, ToSchema, Debug, Clone)]
68pub struct SisuCourseContact {
69    pub name: String,
70    pub role_urn: String,
71    pub email: Option<String>,
72}
73
74#[derive(Serialize, Deserialize, ToSchema, Debug)]
75pub struct Additional {
76    #[serde(skip_serializing_if = "Option::is_none", default)]
77    pub fi: Option<String>,
78    #[serde(skip_serializing_if = "Option::is_none", default)]
79    pub en: Option<String>,
80    #[serde(skip_serializing_if = "Option::is_none", default)]
81    pub sv: Option<String>,
82}
83
84static STRIP_HTML_REGEX: LazyLock<Regex> =
85    LazyLock::new(|| Regex::new(r"<[^>]*>").expect("invalid regex"));
86
87impl Additional {
88    pub fn choose_language(&self, language_code: &String) -> Option<String> {
89        let mut vec = self.as_vec();
90        vec.sort_by(|o1, o2| {
91            if &o1.0 == language_code {
92                return Ordering::Less;
93            }
94            if &o2.0 == language_code {
95                return Ordering::Greater;
96            }
97            if o1.0 == "en" {
98                return Ordering::Less;
99            }
100            if o2.0 == "en" {
101                return Ordering::Greater;
102            }
103            Ordering::Equal
104        });
105
106        let max_length = vec
107            .iter()
108            .map(|n| {
109                let value = &n.1;
110                if let Some(value) = value {
111                    let cleaned = STRIP_HTML_REGEX.replace_all(value, "");
112                    cleaned.len()
113                } else {
114                    0
115                }
116            })
117            .max()
118            .unwrap_or(0);
119
120        let best = vec.iter().find(|o| {
121            let text = &o.1;
122            if let Some(text) = text {
123                let cleaned = STRIP_HTML_REGEX.replace_all(text, "");
124                let len = cleaned.len();
125                if len < max_length / 2 {
126                    return false;
127                }
128                true
129            } else {
130                false
131            }
132        });
133        best.and_then(|o| o.1.clone())
134    }
135    fn as_vec(&self) -> Vec<(String, Option<String>)> {
136        vec![
137            ("en".to_string(), self.en.clone()),
138            ("fi".to_string(), self.fi.clone()),
139            ("sv".to_string(), self.sv.clone()),
140        ]
141    }
142}
143
144#[derive(Serialize, Deserialize, ToSchema, Debug)]
145pub struct Credits {
146    pub min: Option<i64>,
147    pub max: Option<i64>,
148}
149
150#[derive(Serialize, Deserialize, ToSchema, Debug)]
151pub struct Name {
152    pub en: Option<String>,
153    pub fi: Option<String>,
154    pub sv: Option<String>,
155}
156
157#[derive(Serialize, Deserialize, ToSchema, Debug)]
158#[serde(rename_all = "camelCase")]
159pub struct Organisation {
160    pub organisation_id: Option<String>,
161    pub educational_institution_urn: Option<serde_json::Value>,
162    pub role_urn: String,
163    pub share: i64,
164    pub validity_period: Option<OrganisationValidityPeriod>,
165}
166
167#[derive(Serialize, Deserialize, ToSchema, Debug)]
168pub struct OrganisationValidityPeriod {}
169
170#[derive(Serialize, Deserialize, ToSchema, Debug)]
171#[serde(rename_all = "camelCase")]
172pub struct SisuCourseInfoValidityPeriod {
173    pub start_date: Option<String>,
174    pub end_date: Option<String>,
175}
176
177#[derive(Serialize, Deserialize, ToSchema, Debug)]
178#[serde(rename_all = "camelCase")]
179pub struct SearchResult {
180    pub id: String,
181}
182
183#[derive(Serialize, Deserialize, ToSchema, Debug)]
184#[serde(rename_all = "camelCase")]
185pub struct CourseUnitSearchResults {
186    pub search_results: Vec<SearchResult>,
187}
188#[derive(Debug, ToSchema, Serialize, Deserialize, Clone)]
189pub struct SisuDescriptions {
190    outcomes: Option<String>,
191    content: Option<String>,
192    prerequisites: Option<String>,
193    additional: Option<String>,
194    learning_material: Option<String>,
195}
196
197const TIMEOUT_DURATION: Duration = Duration::from_secs(60);
198
199impl SisuClient {
200    fn get_url(&self) -> Result<Url, ParseError> {
201        let base_url = &self.base_url;
202        let is_mock_sisu = bool_env_false_by_default("USE_MOCK_SISU_ENDPOINT");
203        if is_mock_sisu {
204            let mock_path = Url::parse(base_url.as_str())?;
205            mock_path.join("/api/v0/mock-sisu/")
206        } else {
207            Url::parse("https://sisu.helsinki.fi/kori/api/")
208        }
209    }
210
211    pub fn new(base_url: String) -> UtilResult<Self> {
212        if base_url.trim().is_empty() {
213            return Err(UtilError::new(
214                UtilErrorType::Other,
215                "BASE_URL cannot be empty".to_string(),
216                None,
217            ));
218        }
219        Ok(Self { base_url })
220    }
221
222    async fn get_request_sisu(&self, path: String) -> Result<reqwest::Response, UtilError> {
223        let base_url = Self::get_url(self)?;
224        let url = base_url.join(path.as_str())?;
225        let builder = REQWEST_CLIENT.get(url).timeout(TIMEOUT_DURATION);
226
227        builder.send().await.map_err(|e| {
228            util_err!(
229                SisuClientError(SisuErrorVariant::GenericSisuError),
230                "Request to Sisu failed",
231                e
232            )
233        })
234    }
235
236    pub async fn get_course_ids(
237        &self,
238        course_modules: Vec<String>,
239    ) -> UtilResult<Vec<Vec<String>>> {
240        let course_codes = course_modules;
241        let mut course_ids: Vec<Vec<String>> = vec![];
242        let mut invalid_codes: Vec<String> = vec![];
243        for code in course_codes {
244            let path = format!(
245                "course-unit-search?codeQuery={code}&validity=ALL&returnAllGroupVersions=true"
246            );
247            let response = self.get_request_sisu(path).await?;
248
249            if response.status().is_success() {
250                let json: CourseUnitSearchResults =
251                    serde_json::from_str(&response.text().await.unwrap_or("{}".to_string()))?;
252                let ids: Vec<String> = json.search_results.into_iter().map(|x| x.id).collect();
253
254                if ids.is_empty() {
255                    invalid_codes.push(code);
256                } else {
257                    course_ids.push(ids);
258                }
259            } else if response.status() == 404 {
260                return Err(util_err!(
261                    SisuClientError(SisuErrorVariant::SisuResourceNotFound),
262                    "Course ids not found".to_string()
263                ));
264            } else {
265                return Err(util_err!(
266                    SisuClientError(SisuErrorVariant::GenericSisuError),
267                    "Something went wrong when fetching course ids".to_string()
268                ));
269            }
270        }
271
272        if !invalid_codes.is_empty() {
273            return Err(util_err!(
274                SisuClientError(SisuErrorVariant::InvalidCourseCode),
275                format!("No data found with codes: {invalid_codes:?}")
276            ));
277        }
278        Ok(course_ids)
279    }
280
281    pub async fn get_course_info(
282        &self,
283        course_ids: Vec<Vec<String>>,
284    ) -> UtilResult<Vec<SisuCourseInfoElement>> {
285        let mut data_vec: Vec<SisuCourseInfoElement> = vec![];
286        for id in course_ids {
287            if let Some(first) = id.first() {
288                let path = format!("course-units/v1/{first}");
289                let response = self.get_request_sisu(path).await?;
290
291                if response.status().is_success() {
292                    let json: SisuCourseInfoElement =
293                        serde_json::from_str(&response.text().await.unwrap_or("{}".to_string()))?;
294                    data_vec.push(json);
295                } else if response.status() == 404 {
296                    return Err(util_err!(
297                        SisuClientError(SisuErrorVariant::SisuResourceNotFound),
298                        "Course info not found".to_string()
299                    ));
300                } else {
301                    return Err(util_err!(
302                        SisuClientError(SisuErrorVariant::GenericSisuError),
303                        "Something went wrong when fetching course info".to_string()
304                    ));
305                }
306            } else {
307                return Err(util_err!(
308                    SisuClientError(SisuErrorVariant::SisuResourceNotFound),
309                    "No courses found with course id".to_string()
310                ));
311            }
312        }
313        Ok(data_vec)
314    }
315    pub fn parse_course_info(
316        course_info: Vec<SisuCourseInfoElement>,
317        course_language: String,
318    ) -> HashMap<String, SisuDescriptions> {
319        let mut course_desc: HashMap<String, SisuDescriptions> = HashMap::new();
320
321        for module in course_info {
322            let outcome = module
323                .outcomes
324                .and_then(|x| x.choose_language(&course_language).to_owned());
325            let content = module
326                .content
327                .and_then(|x| x.choose_language(&course_language).to_owned());
328            let preq = module
329                .prerequisites
330                .and_then(|x| x.choose_language(&course_language).to_owned());
331            let material = module
332                .learning_material
333                .and_then(|x| x.choose_language(&course_language).to_owned());
334
335            let add = module
336                .additional
337                .and_then(|x| x.choose_language(&course_language).to_owned());
338
339            let descriptions = SisuDescriptions {
340                outcomes: outcome,
341                content,
342                prerequisites: preq,
343                learning_material: material,
344                additional: add,
345            };
346            course_desc.insert(module.code, descriptions);
347        }
348        course_desc
349    }
350
351    /// Resolves a UH course code to its responsible-teacher contacts, via a course-id lookup
352    /// followed by a course-info fetch. A contact with no `person` name is dropped: it carries
353    /// nothing a support admin can act on.
354    pub async fn get_course_contacts(
355        &self,
356        uh_course_code: &str,
357    ) -> UtilResult<Vec<SisuCourseContact>> {
358        let course_ids = self
359            .get_course_ids(vec![uh_course_code.to_string()])
360            .await?;
361        let course_info = self.get_course_info(course_ids).await?;
362
363        let contacts = course_info
364            .into_iter()
365            .flat_map(|info| info.responsibility_infos.unwrap_or_default())
366            .filter_map(|info| {
367                let name = info.person.as_ref().and_then(|person| {
368                    let full_name = [person.first_names.as_deref(), person.last_name.as_deref()]
369                        .into_iter()
370                        .flatten()
371                        .collect::<Vec<_>>()
372                        .join(" ");
373                    (!full_name.is_empty()).then_some(full_name)
374                })?;
375                let email = info.person.as_ref().and_then(|person| person.email.clone());
376                Some(SisuCourseContact {
377                    name,
378                    role_urn: info.role_urn,
379                    email,
380                })
381            })
382            .collect();
383        Ok(contacts)
384    }
385
386    pub fn mock_for_test() -> Self {
387        Self {
388            base_url: String::from("mock-base-url"),
389        }
390    }
391}