1use crate::{
2 cache::Cache, error::util_error::SisuErrorVariant, prelude::*,
3 url_encoding::percent_encode_component,
4};
5
6#[derive(Debug, Clone)]
7pub struct SisuClient {
8 base_url: String,
9}
10
11use chrono::NaiveDate;
12use headless_lms_base::config::bool_env_false_by_default;
13use regex::Regex;
14use serde::{Deserialize, Serialize};
15use std::sync::LazyLock;
16use std::time::Duration;
17use std::{cmp::Ordering, collections::HashMap, collections::HashSet};
18use utoipa::ToSchema;
19pub type SisuCourseInfo = Vec<SisuCourseInfoElement>;
20use url::{ParseError, Url};
21
22#[derive(Serialize, Deserialize, ToSchema, Debug)]
23#[serde(rename_all = "camelCase")]
24pub struct SisuCourseInfoElement {
25 pub id: String,
26 pub university_org_ids: Vec<String>,
27 pub group_id: String,
28 pub credits: Credits,
29 pub completion_methods: Vec<Option<serde_json::Value>>,
30 pub name: Name,
31 pub code: String,
32 pub abbreviation: Option<String>,
33 pub validity_period: SisuCourseInfoValidityPeriod,
34 pub grade_scale_id: String,
35 pub tweet_text: Option<serde_json::Value>,
36 pub outcomes: Option<Additional>,
37 pub prerequisites: Option<Additional>,
38 pub content: Option<Additional>,
39 pub additional: Option<Additional>,
40 pub learning_material: Option<Additional>,
41 pub literature: Vec<Option<serde_json::Value>>,
42 pub study_level: String,
43 pub course_unit_type: String,
44 pub subject: Option<serde_json::Value>,
45 pub cefr_level: Option<serde_json::Value>,
46 pub organisations: Vec<Organisation>,
47 pub possible_attainment_languages: Vec<String>,
48 pub part_of_degree: Option<serde_json::Value>,
49 #[serde(default)]
50 pub responsibility_infos: Option<Vec<ResponsibilityInfo>>,
51}
52
53#[derive(Serialize, Deserialize, ToSchema, Debug, Clone)]
56#[serde(rename_all = "camelCase")]
57pub struct ResponsibilityInfo {
58 pub role_urn: String,
59 pub person_id: Option<String>,
60 pub text: Option<Additional>,
61 #[serde(default)]
62 pub validity_period: Option<SisuCourseInfoValidityPeriod>,
63}
64
65#[derive(Serialize, Deserialize, ToSchema, Debug)]
67#[serde(rename_all = "camelCase")]
68pub struct SisuPerson {
69 pub id: String,
70 pub first_name: Option<String>,
71 pub last_name: Option<String>,
72 pub email_address: Option<String>,
73 #[serde(default)]
74 pub titles: Vec<Additional>,
75}
76
77#[derive(Serialize, Deserialize, Debug, Clone)]
79pub struct SisuCourseContacts {
80 pub course_code: String,
81 pub contacts: Vec<SisuCourseContact>,
82 pub responsible_organisations: Vec<String>,
85}
86
87#[derive(Serialize, Deserialize, Debug, Clone)]
89pub struct SisuCourseContact {
90 pub name: Option<String>,
91 pub role: String,
94 pub titles: Vec<String>,
95 pub email: Option<String>,
96 pub note: Option<String>,
97 pub source: SisuContactSource,
98}
99
100#[derive(Serialize, Deserialize, Debug, Clone)]
103#[serde(tag = "kind", rename_all = "snake_case")]
104pub enum SisuContactSource {
105 CourseUnit,
106 Realisation {
107 realisation_name: Option<String>,
108 start_date: Option<String>,
109 end_date: Option<String>,
110 },
111}
112
113#[derive(Serialize, Deserialize, ToSchema, Debug, Clone)]
114pub struct Additional {
115 #[serde(skip_serializing_if = "Option::is_none", default)]
116 pub fi: Option<String>,
117 #[serde(skip_serializing_if = "Option::is_none", default)]
118 pub en: Option<String>,
119 #[serde(skip_serializing_if = "Option::is_none", default)]
120 pub sv: Option<String>,
121}
122
123static STRIP_HTML_REGEX: LazyLock<Regex> =
124 LazyLock::new(|| Regex::new(r"<[^>]*>").expect("invalid regex"));
125
126impl Additional {
127 pub fn choose_language(&self, language_code: &String) -> Option<String> {
128 let mut vec = self.as_vec();
129 vec.sort_by(|o1, o2| {
130 if &o1.0 == language_code {
131 return Ordering::Less;
132 }
133 if &o2.0 == language_code {
134 return Ordering::Greater;
135 }
136 if o1.0 == "en" {
137 return Ordering::Less;
138 }
139 if o2.0 == "en" {
140 return Ordering::Greater;
141 }
142 Ordering::Equal
143 });
144
145 let max_length = vec
146 .iter()
147 .map(|n| {
148 let value = &n.1;
149 if let Some(value) = value {
150 let cleaned = STRIP_HTML_REGEX.replace_all(value, "");
151 cleaned.len()
152 } else {
153 0
154 }
155 })
156 .max()
157 .unwrap_or(0);
158
159 let best = vec.iter().find(|o| {
160 let text = &o.1;
161 if let Some(text) = text {
162 let cleaned = STRIP_HTML_REGEX.replace_all(text, "");
163 let len = cleaned.len();
164 if len < max_length / 2 {
165 return false;
166 }
167 true
168 } else {
169 false
170 }
171 });
172 best.and_then(|o| o.1.clone())
173 }
174 pub fn preferring_english(&self) -> Option<String> {
176 [&self.en, &self.fi, &self.sv]
177 .into_iter()
178 .flatten()
179 .find(|text| !text.trim().is_empty())
180 .cloned()
181 }
182
183 fn as_vec(&self) -> Vec<(String, Option<String>)> {
184 vec![
185 ("en".to_string(), self.en.clone()),
186 ("fi".to_string(), self.fi.clone()),
187 ("sv".to_string(), self.sv.clone()),
188 ]
189 }
190}
191
192#[derive(Serialize, Deserialize, ToSchema, Debug)]
193pub struct Credits {
194 pub min: Option<f32>,
195 pub max: Option<f32>,
196}
197
198#[derive(Serialize, Deserialize, ToSchema, Debug)]
199pub struct Name {
200 pub en: Option<String>,
201 pub fi: Option<String>,
202 pub sv: Option<String>,
203}
204
205#[derive(Serialize, Deserialize, ToSchema, Debug)]
206#[serde(rename_all = "camelCase")]
207pub struct Organisation {
208 pub organisation_id: Option<String>,
209 pub educational_institution_urn: Option<serde_json::Value>,
210 pub role_urn: String,
211 pub share: f64,
212 pub validity_period: Option<OrganisationValidityPeriod>,
213}
214
215#[derive(Serialize, Deserialize, ToSchema, Debug)]
216pub struct OrganisationValidityPeriod {}
217
218#[derive(Serialize, Deserialize, ToSchema, Debug, Clone)]
219#[serde(rename_all = "camelCase")]
220pub struct SisuCourseInfoValidityPeriod {
221 pub start_date: Option<String>,
222 pub end_date: Option<String>,
223}
224
225#[derive(Serialize, Deserialize, ToSchema, Debug)]
226#[serde(rename_all = "camelCase")]
227pub struct SearchResult {
228 pub id: String,
229 #[serde(default)]
230 pub code: Option<String>,
231}
232
233#[derive(Serialize, Deserialize, ToSchema, Debug)]
234#[serde(rename_all = "camelCase")]
235pub struct CourseUnitSearchResults {
236 pub search_results: Vec<SearchResult>,
237}
238#[derive(Debug, ToSchema, Serialize, Deserialize, Clone)]
239pub struct SisuDescriptions {
240 outcomes: Option<String>,
241 content: Option<String>,
242 prerequisites: Option<String>,
243 additional: Option<String>,
244 learning_material: Option<String>,
245}
246
247const TIMEOUT_DURATION: Duration = Duration::from_secs(60);
248
249const COURSE_CONTACTS_CACHE_TTL: Duration = Duration::from_secs(3 * 60 * 60);
251
252#[derive(Deserialize, Debug)]
255#[serde(rename_all = "camelCase")]
256struct CourseUnitResponsibilities {
257 code: String,
258 validity_period: Option<SisuCourseInfoValidityPeriod>,
259 #[serde(default)]
260 responsibility_infos: Vec<ResponsibilityInfo>,
261 #[serde(default)]
262 organisations: Vec<CourseUnitOrganisation>,
263 #[serde(default)]
264 completion_methods: Vec<CompletionMethodAssessmentItems>,
265}
266
267#[derive(Deserialize, Debug)]
268#[serde(rename_all = "camelCase")]
269struct CourseUnitOrganisation {
270 organisation_id: Option<String>,
271 role_urn: String,
272}
273
274#[derive(Deserialize, Debug)]
275#[serde(rename_all = "camelCase")]
276struct CompletionMethodAssessmentItems {
277 #[serde(default)]
278 assessment_item_ids: Vec<String>,
279}
280
281#[derive(Deserialize, Debug)]
282#[serde(rename_all = "camelCase")]
283struct RealisationResponsibilities {
284 id: String,
285 name: Option<Additional>,
286 activity_period: Option<SisuCourseInfoValidityPeriod>,
287 flow_state: Option<String>,
288 #[serde(default)]
289 responsibility_infos: Vec<ResponsibilityInfo>,
290}
291
292#[derive(Deserialize, Debug)]
293struct SisuOrganisation {
294 name: Option<Additional>,
295}
296
297const RESPONSIBLE_ORGANISATION_ROLE_URN: &str =
298 "urn:code:organisation-role:responsible-organisation";
299
300fn period_contains(period: Option<&SisuCourseInfoValidityPeriod>, today: NaiveDate) -> bool {
303 let Some(period) = period else {
304 return true;
305 };
306 let starts_by_today = parse_sisu_date(period.start_date.as_deref()).is_none_or(|d| d <= today);
307 let ends_after_today = parse_sisu_date(period.end_date.as_deref()).is_none_or(|d| today < d);
308 starts_by_today && ends_after_today
309}
310
311fn parse_sisu_date(date: Option<&str>) -> Option<NaiveDate> {
312 date.and_then(|d| NaiveDate::parse_from_str(d, "%Y-%m-%d").ok())
313}
314
315fn start_date(period: Option<&SisuCourseInfoValidityPeriod>) -> Option<NaiveDate> {
316 parse_sisu_date(period.and_then(|p| p.start_date.as_deref()))
317}
318
319impl SisuClient {
320 fn get_url(&self) -> Result<Url, ParseError> {
321 let base_url = &self.base_url;
322 let is_mock_sisu = bool_env_false_by_default("USE_MOCK_SISU_ENDPOINT");
323 if is_mock_sisu {
324 let mock_path = Url::parse(base_url.as_str())?;
325 mock_path.join("/api/v0/mock-sisu/")
326 } else {
327 Url::parse("https://sisu.helsinki.fi/kori/api/")
328 }
329 }
330
331 pub fn new(base_url: String) -> UtilResult<Self> {
332 if base_url.trim().is_empty() {
333 return Err(UtilError::new(
334 UtilErrorType::Other,
335 "BASE_URL cannot be empty".to_string(),
336 None,
337 ));
338 }
339 Ok(Self { base_url })
340 }
341
342 async fn get_request_sisu(&self, path: String) -> Result<reqwest::Response, UtilError> {
343 let base_url = Self::get_url(self)?;
344 let url = base_url.join(path.as_str())?;
345 let builder = REQWEST_CLIENT.get(url).timeout(TIMEOUT_DURATION);
346
347 builder.send().await.map_err(|e| {
348 util_err!(
349 SisuClientError(SisuErrorVariant::GenericSisuError),
350 "Request to Sisu failed",
351 e
352 )
353 })
354 }
355
356 pub async fn get_course_ids(
357 &self,
358 course_modules: Vec<String>,
359 ) -> UtilResult<Vec<Vec<String>>> {
360 let course_codes = course_modules;
361 let mut course_ids: Vec<Vec<String>> = vec![];
362 let mut invalid_codes: Vec<String> = vec![];
363 for code in course_codes {
364 let path = format!(
365 "course-unit-search?codeQuery={code}&validity=ALL&returnAllGroupVersions=true"
366 );
367 let response = self.get_request_sisu(path).await?;
368
369 if response.status().is_success() {
370 let json: CourseUnitSearchResults =
371 serde_json::from_str(&response.text().await.unwrap_or("{}".to_string()))?;
372 let ids: Vec<String> = json.search_results.into_iter().map(|x| x.id).collect();
373
374 if ids.is_empty() {
375 invalid_codes.push(code);
376 } else {
377 course_ids.push(ids);
378 }
379 } else if response.status() == 404 {
380 return Err(util_err!(
381 SisuClientError(SisuErrorVariant::SisuResourceNotFound),
382 "Course ids not found".to_string()
383 ));
384 } else {
385 return Err(util_err!(
386 SisuClientError(SisuErrorVariant::GenericSisuError),
387 "Something went wrong when fetching course ids".to_string()
388 ));
389 }
390 }
391
392 if !invalid_codes.is_empty() {
393 return Err(util_err!(
394 SisuClientError(SisuErrorVariant::InvalidCourseCode),
395 format!("No data found with codes: {invalid_codes:?}")
396 ));
397 }
398 Ok(course_ids)
399 }
400
401 pub async fn get_course_info(
402 &self,
403 course_ids: Vec<Vec<String>>,
404 ) -> UtilResult<Vec<SisuCourseInfoElement>> {
405 let mut data_vec: Vec<SisuCourseInfoElement> = vec![];
406 for id in course_ids {
407 if let Some(first) = id.first() {
408 let path = format!("course-units/v1/{first}");
409 let response = self.get_request_sisu(path).await?;
410
411 if response.status().is_success() {
412 let json: SisuCourseInfoElement =
413 serde_json::from_str(&response.text().await.unwrap_or("{}".to_string()))?;
414 data_vec.push(json);
415 } else if response.status() == 404 {
416 return Err(util_err!(
417 SisuClientError(SisuErrorVariant::SisuResourceNotFound),
418 "Course info not found".to_string()
419 ));
420 } else {
421 return Err(util_err!(
422 SisuClientError(SisuErrorVariant::GenericSisuError),
423 "Something went wrong when fetching course info".to_string()
424 ));
425 }
426 } else {
427 return Err(util_err!(
428 SisuClientError(SisuErrorVariant::SisuResourceNotFound),
429 "No courses found with course id".to_string()
430 ));
431 }
432 }
433 Ok(data_vec)
434 }
435 pub fn parse_course_info(
436 course_info: Vec<SisuCourseInfoElement>,
437 course_language: String,
438 ) -> HashMap<String, SisuDescriptions> {
439 let mut course_desc: HashMap<String, SisuDescriptions> = HashMap::new();
440
441 for module in course_info {
442 let outcome = module
443 .outcomes
444 .and_then(|x| x.choose_language(&course_language).to_owned());
445 let content = module
446 .content
447 .and_then(|x| x.choose_language(&course_language).to_owned());
448 let preq = module
449 .prerequisites
450 .and_then(|x| x.choose_language(&course_language).to_owned());
451 let material = module
452 .learning_material
453 .and_then(|x| x.choose_language(&course_language).to_owned());
454
455 let add = module
456 .additional
457 .and_then(|x| x.choose_language(&course_language).to_owned());
458
459 let descriptions = SisuDescriptions {
460 outcomes: outcome,
461 content,
462 prerequisites: preq,
463 learning_material: material,
464 additional: add,
465 };
466 course_desc.insert(module.code, descriptions);
467 }
468 course_desc
469 }
470
471 pub async fn get_course_contacts(
477 &self,
478 cache: &Cache,
479 uh_course_code: &str,
480 ) -> UtilResult<SisuCourseContacts> {
481 let cache_key = format!(
482 "sisu-course-contacts:{}",
483 uh_course_code.trim().to_uppercase()
484 );
485 cache
486 .get_or_set(cache_key, COURSE_CONTACTS_CACHE_TTL, || {
487 self.fetch_course_contacts(uh_course_code)
488 })
489 .await
490 }
491
492 async fn fetch_course_contacts(&self, uh_course_code: &str) -> UtilResult<SisuCourseContacts> {
493 let today = chrono::Utc::now().date_naive();
494 let course_unit = self.get_current_course_unit(uh_course_code, today).await?;
495
496 let mut contacts = Vec::new();
497 let mut seen_person_ids = HashSet::new();
498 self.push_contacts(
499 &mut contacts,
500 &mut seen_person_ids,
501 &course_unit.responsibility_infos,
502 SisuContactSource::CourseUnit,
503 today,
504 )
505 .await?;
506
507 let mut assessment_item_ids: Vec<String> = course_unit
508 .completion_methods
509 .iter()
510 .flat_map(|method| method.assessment_item_ids.iter().cloned())
511 .collect();
512 assessment_item_ids.sort();
513 assessment_item_ids.dedup();
514 for realisation in self
515 .get_current_realisations(&assessment_item_ids, today)
516 .await?
517 {
518 let source = SisuContactSource::Realisation {
519 realisation_name: realisation
520 .name
521 .as_ref()
522 .and_then(Additional::preferring_english),
523 start_date: realisation
524 .activity_period
525 .as_ref()
526 .and_then(|p| p.start_date.clone()),
527 end_date: realisation
528 .activity_period
529 .as_ref()
530 .and_then(|p| p.end_date.clone()),
531 };
532 self.push_contacts(
533 &mut contacts,
534 &mut seen_person_ids,
535 &realisation.responsibility_infos,
536 source,
537 today,
538 )
539 .await?;
540 }
541
542 let mut responsible_organisations = Vec::new();
543 for organisation in course_unit
544 .organisations
545 .iter()
546 .filter(|o| o.role_urn == RESPONSIBLE_ORGANISATION_ROLE_URN)
547 {
548 let Some(organisation_id) = &organisation.organisation_id else {
549 continue;
550 };
551 if let Some(name) = self.get_organisation_name(organisation_id).await? {
552 responsible_organisations.push(name);
553 }
554 }
555
556 Ok(SisuCourseContacts {
557 course_code: course_unit.code,
558 contacts,
559 responsible_organisations,
560 })
561 }
562
563 async fn get_current_course_unit(
565 &self,
566 uh_course_code: &str,
567 today: NaiveDate,
568 ) -> UtilResult<CourseUnitResponsibilities> {
569 let path = format!(
570 "course-unit-search?codeQuery={}&validity=ALL&returnAllGroupVersions=true",
571 percent_encode_component(uh_course_code)
572 );
573 let search: CourseUnitSearchResults = self.get_sisu_json(path).await?.ok_or_else(|| {
574 util_err!(
575 SisuClientError(SisuErrorVariant::InvalidCourseCode),
576 format!("No course unit found with code {uh_course_code}")
577 )
578 })?;
579 let ids = search.search_results.into_iter().filter(|result| {
581 result
582 .code
583 .as_deref()
584 .is_none_or(|code| code.eq_ignore_ascii_case(uh_course_code))
585 });
586
587 let mut versions = Vec::new();
588 for result in ids {
589 let version: Option<CourseUnitResponsibilities> = self
590 .get_sisu_json(format!(
591 "course-units/v1/{}",
592 percent_encode_component(&result.id)
593 ))
594 .await?;
595 versions.extend(version.filter(|v| v.code.eq_ignore_ascii_case(uh_course_code)));
596 }
597
598 let current = versions
600 .iter()
601 .enumerate()
602 .max_by_key(|(_, v)| {
603 (
604 period_contains(v.validity_period.as_ref(), today),
605 start_date(v.validity_period.as_ref()),
606 )
607 })
608 .map(|(index, _)| index);
609 match current {
610 Some(index) => Ok(versions.swap_remove(index)),
611 None => Err(util_err!(
612 SisuClientError(SisuErrorVariant::InvalidCourseCode),
613 format!("No course unit found with code {uh_course_code}")
614 )),
615 }
616 }
617
618 async fn get_current_realisations(
621 &self,
622 assessment_item_ids: &[String],
623 today: NaiveDate,
624 ) -> UtilResult<Vec<RealisationResponsibilities>> {
625 let mut realisations: Vec<RealisationResponsibilities> = Vec::new();
626 for assessment_item_id in assessment_item_ids {
627 let path = format!(
628 "course-unit-realisations-by-assessment-item-id?assessmentItemId={}&activityStatus=ONGOING_AND_FUTURE",
629 percent_encode_component(assessment_item_id)
630 );
631 let found: Vec<RealisationResponsibilities> =
632 self.get_sisu_json(path).await?.unwrap_or_default();
633 for realisation in found {
634 if realisation.flow_state.as_deref() == Some("PUBLISHED")
635 && !realisations.iter().any(|r| r.id == realisation.id)
636 {
637 realisations.push(realisation);
638 }
639 }
640 }
641
642 if realisations
643 .iter()
644 .any(|r| period_contains(r.activity_period.as_ref(), today))
645 {
646 realisations.retain(|r| period_contains(r.activity_period.as_ref(), today));
647 } else {
648 realisations.sort_by_key(|r| start_date(r.activity_period.as_ref()));
649 realisations.truncate(1);
650 }
651 Ok(realisations)
652 }
653
654 async fn push_contacts(
655 &self,
656 contacts: &mut Vec<SisuCourseContact>,
657 seen_person_ids: &mut HashSet<String>,
658 responsibility_infos: &[ResponsibilityInfo],
659 source: SisuContactSource,
660 today: NaiveDate,
661 ) -> UtilResult<()> {
662 for info in responsibility_infos {
663 if !period_contains(info.validity_period.as_ref(), today) {
664 continue;
665 }
666 let role = info
667 .role_urn
668 .rsplit(':')
669 .next()
670 .unwrap_or(&info.role_urn)
671 .to_string();
672 let note = info
673 .text
674 .as_ref()
675 .and_then(Additional::preferring_english)
676 .map(|text| STRIP_HTML_REGEX.replace_all(&text, "").trim().to_string())
677 .filter(|text| text.chars().any(char::is_alphanumeric));
679 let person = match &info.person_id {
680 Some(person_id) => {
681 if seen_person_ids.contains(person_id) {
682 continue;
683 }
684 self.get_person(person_id).await?
685 }
686 None => None,
687 };
688 if person.is_none() && note.is_none() {
689 continue;
690 }
691 if let Some(person_id) = &info.person_id {
692 seen_person_ids.insert(person_id.clone());
693 }
694 contacts.push(SisuCourseContact {
695 name: person.as_ref().and_then(|p| {
696 let full_name = [p.first_name.as_deref(), p.last_name.as_deref()]
697 .into_iter()
698 .flatten()
699 .collect::<Vec<_>>()
700 .join(" ");
701 (!full_name.is_empty()).then_some(full_name)
702 }),
703 role,
704 titles: person
705 .as_ref()
706 .map(|p| {
707 p.titles
708 .iter()
709 .filter_map(Additional::preferring_english)
710 .collect()
711 })
712 .unwrap_or_default(),
713 email: person.and_then(|p| p.email_address),
714 note,
715 source: source.clone(),
716 });
717 }
718 Ok(())
719 }
720
721 async fn get_person(&self, person_id: &str) -> UtilResult<Option<SisuPerson>> {
723 self.get_sisu_json(format!(
724 "persons/v1/{}",
725 percent_encode_component(person_id)
726 ))
727 .await
728 }
729
730 async fn get_organisation_name(&self, organisation_id: &str) -> UtilResult<Option<String>> {
731 let organisation: Option<SisuOrganisation> = self
732 .get_sisu_json(format!(
733 "organisations/{}",
734 percent_encode_component(organisation_id)
735 ))
736 .await?;
737 Ok(organisation
738 .and_then(|o| o.name)
739 .and_then(|name| name.preferring_english()))
740 }
741
742 async fn get_sisu_json<T: serde::de::DeserializeOwned>(
744 &self,
745 path: String,
746 ) -> UtilResult<Option<T>> {
747 let response = self.get_request_sisu(path).await?;
748 let status = response.status();
749 if status == reqwest::StatusCode::NOT_FOUND || status == reqwest::StatusCode::BAD_REQUEST {
750 return Ok(None);
751 }
752 if !status.is_success() {
753 return Err(util_err!(
754 SisuClientError(SisuErrorVariant::GenericSisuError),
755 format!("Sisu answered {status}")
756 ));
757 }
758 let body = response.text().await.map_err(|e| {
759 util_err!(
760 SisuClientError(SisuErrorVariant::GenericSisuError),
761 "Reading a Sisu response failed",
762 e
763 )
764 })?;
765 Ok(Some(serde_json::from_str(&body)?))
766 }
767
768 pub fn mock_for_test() -> Self {
769 Self {
770 base_url: String::from("mock-base-url"),
771 }
772 }
773}