headless_lms_utils/services/
sisu.rs1use 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#[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#[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 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}