headless_lms_models/
exercise_service_info.rs

1use std::collections::HashMap;
2
3use futures::future::BoxFuture;
4use url::Url;
5
6use crate::{
7    exercise_services::{
8        ExerciseService, get_exercise_service_by_exercise_type, get_exercise_services,
9    },
10    prelude::*,
11};
12
13#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
14pub struct ExerciseServiceInfo {
15    pub exercise_service_id: Uuid,
16    pub created_at: DateTime<Utc>,
17    pub updated_at: DateTime<Utc>,
18    pub user_interface_iframe_path: String,
19    pub grade_endpoint_path: String,
20    pub public_spec_endpoint_path: String,
21    pub model_solution_spec_endpoint_path: String,
22    //#[serde(skip_serializing_if = "Option::is_none")]
23    pub has_custom_view: bool,
24    pub csv_export_definitions_endpoint_path: Option<String>,
25    pub csv_export_answers_endpoint_path: Option<String>,
26}
27
28#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
29pub struct PathInfo {
30    pub exercise_service_id: Uuid,
31    pub user_interface_iframe_path: String,
32    pub grade_endpoint_path: String,
33    pub public_spec_endpoint_path: String,
34    pub model_solution_spec_endpoint_path: String,
35    // #[serde(skip_serializing_if = "Option::is_none")]
36    pub has_custom_view: bool,
37}
38
39#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
40#[cfg_attr(feature = "ts_rs", derive(TS))]
41pub struct CourseMaterialExerciseServiceInfo {
42    pub exercise_iframe_url: String,
43}
44
45#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
46#[cfg_attr(feature = "ts_rs", derive(TS))]
47pub struct ExerciseServiceInfoApi {
48    pub service_name: String,
49    pub user_interface_iframe_path: String,
50    pub grade_endpoint_path: String,
51    pub public_spec_endpoint_path: String,
52    pub model_solution_spec_endpoint_path: String,
53    #[serde(skip_serializing_if = "Option::is_none")]
54    pub has_custom_view: Option<bool>,
55    #[serde(skip_serializing_if = "Option::is_none")]
56    pub csv_export_definitions_endpoint_path: Option<String>,
57    #[serde(skip_serializing_if = "Option::is_none")]
58    pub csv_export_answers_endpoint_path: Option<String>,
59}
60
61pub async fn insert(
62    conn: &mut PgConnection,
63    exercise_service_info: &PathInfo,
64) -> ModelResult<ExerciseServiceInfo> {
65    let res = sqlx::query_as!(
66        ExerciseServiceInfo,
67        "
68INSERT INTO exercise_service_info (
69    exercise_service_id,
70    user_interface_iframe_path,
71    grade_endpoint_path,
72    public_spec_endpoint_path,
73    model_solution_spec_endpoint_path,
74    has_custom_view
75  )
76VALUES ($1, $2, $3, $4, $5, $6)
77RETURNING *
78",
79        exercise_service_info.exercise_service_id,
80        exercise_service_info.user_interface_iframe_path,
81        exercise_service_info.grade_endpoint_path,
82        exercise_service_info.public_spec_endpoint_path,
83        exercise_service_info.model_solution_spec_endpoint_path,
84        exercise_service_info.has_custom_view
85    )
86    .fetch_one(conn)
87    .await?;
88    Ok(res)
89}
90
91pub async fn fetch_and_upsert_service_info(
92    conn: &mut PgConnection,
93    exercise_service: &ExerciseService,
94    fetch_service_info: impl Fn(Url) -> BoxFuture<'static, ModelResult<ExerciseServiceInfoApi>>,
95) -> ModelResult<ExerciseServiceInfo> {
96    let url = match exercise_service
97        .internal_url
98        .clone()
99        .map(|url| Url::parse(&url))
100    {
101        Some(Ok(url)) => url.to_string(),
102
103        Some(Err(e)) => {
104            warn!(
105                "Internal_url provided for {} is not a valid url. Using public_url instead. Error: {}",
106                exercise_service.name,
107                e.to_string()
108            );
109            exercise_service.public_url.clone()
110        }
111        None => exercise_service.public_url.clone(),
112    };
113    let fetched_info = fetch_service_info(url.parse()?).await?;
114    let res = upsert_service_info(conn, exercise_service.id, &fetched_info).await?;
115    Ok(res)
116}
117
118pub async fn upsert_service_info(
119    conn: &mut PgConnection,
120    exercise_service_id: Uuid,
121    update: &ExerciseServiceInfoApi,
122) -> ModelResult<ExerciseServiceInfo> {
123    let res = sqlx::query_as!(
124        ExerciseServiceInfo,
125        r#"
126INSERT INTO exercise_service_info(
127    exercise_service_id,
128    user_interface_iframe_path,
129    grade_endpoint_path,
130    public_spec_endpoint_path,
131    model_solution_spec_endpoint_path,
132    has_custom_view,
133    csv_export_definitions_endpoint_path,
134    csv_export_answers_endpoint_path
135  )
136VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
137ON CONFLICT(exercise_service_id) DO UPDATE
138SET user_interface_iframe_path = $2,
139  grade_endpoint_path = $3,
140  public_spec_endpoint_path = $4,
141  model_solution_spec_endpoint_path = $5,
142  has_custom_view = $6,
143  csv_export_definitions_endpoint_path = $7,
144  csv_export_answers_endpoint_path = $8
145RETURNING *
146    "#,
147        exercise_service_id,
148        update.user_interface_iframe_path,
149        update.grade_endpoint_path,
150        update.public_spec_endpoint_path,
151        update.model_solution_spec_endpoint_path,
152        update.has_custom_view.unwrap_or_else(|| false),
153        update.csv_export_definitions_endpoint_path.as_deref(),
154        update.csv_export_answers_endpoint_path.as_deref()
155    )
156    .fetch_one(conn)
157    .await?;
158    Ok(res)
159}
160
161pub async fn get_service_info(
162    conn: &mut PgConnection,
163    exercise_service_id: Uuid,
164) -> ModelResult<ExerciseServiceInfo> {
165    let res = sqlx::query_as!(
166        ExerciseServiceInfo,
167        r#"
168SELECT *
169FROM exercise_service_info
170WHERE exercise_service_id = $1
171    "#,
172        exercise_service_id
173    )
174    .fetch_one(conn)
175    .await?;
176    Ok(res)
177}
178
179pub async fn get_service_info_by_exercise_type(
180    conn: &mut PgConnection,
181    exercise_type: &str,
182    fetch_service_info: impl Fn(Url) -> BoxFuture<'static, ModelResult<ExerciseServiceInfoApi>>,
183) -> ModelResult<ExerciseServiceInfo> {
184    let exercise_service = get_exercise_service_by_exercise_type(conn, exercise_type).await?;
185    let service_info =
186        get_upsert_service_info_by_exercise_service(conn, &exercise_service, fetch_service_info)
187            .await?;
188    Ok(service_info)
189}
190
191pub async fn get_all_exercise_services_by_type(
192    conn: &mut PgConnection,
193) -> ModelResult<HashMap<String, (ExerciseService, ExerciseServiceInfo)>> {
194    let mut exercise_services_by_type = HashMap::new();
195    for exercise_service in get_exercise_services(conn).await? {
196        match get_service_info_by_exercise_service(conn, &exercise_service).await {
197            Ok(Some(info)) => {
198                exercise_services_by_type
199                    .insert(exercise_service.slug.clone(), (exercise_service, info));
200            }
201            _ => {
202                tracing::error!(
203                    "No corresponding service info found for {} ({})",
204                    exercise_service.name,
205                    exercise_service.id
206                );
207            }
208        }
209    }
210    Ok(exercise_services_by_type)
211}
212
213pub async fn get_upsert_all_exercise_services_by_type(
214    conn: &mut PgConnection,
215    fetch_service_info: impl Fn(Url) -> BoxFuture<'static, ModelResult<ExerciseServiceInfoApi>>,
216) -> ModelResult<HashMap<String, (ExerciseService, ExerciseServiceInfo)>> {
217    let mut exercise_services_by_type = HashMap::new();
218    for exercise_service in get_exercise_services(conn).await? {
219        match get_upsert_service_info_by_exercise_service(
220            conn,
221            &exercise_service,
222            &fetch_service_info,
223        )
224        .await
225        {
226            Ok(info) => {
227                exercise_services_by_type
228                    .insert(exercise_service.slug.clone(), (exercise_service, info));
229            }
230            _ => {
231                tracing::error!(
232                    "No corresponding service info found for {} ({})",
233                    exercise_service.name,
234                    exercise_service.id
235                );
236            }
237        }
238    }
239    Ok(exercise_services_by_type)
240}
241
242pub async fn get_selected_exercise_services_by_type(
243    conn: &mut PgConnection,
244    slugs: &[String],
245    fetch_service_info: impl Fn(Url) -> BoxFuture<'static, ModelResult<ExerciseServiceInfoApi>>,
246) -> ModelResult<HashMap<String, (ExerciseService, ExerciseServiceInfo)>> {
247    let selected_services = sqlx::query_as!(
248        ExerciseService,
249        "
250SELECT *
251FROM exercise_services
252WHERE slug = ANY($1);",
253        slugs,
254    )
255    .fetch_all(&mut *conn)
256    .await?;
257    let mut exercise_services_by_type = HashMap::new();
258    for exercise_service in selected_services {
259        let info = get_upsert_service_info_by_exercise_service(
260            conn,
261            &exercise_service,
262            &fetch_service_info,
263        )
264        .await?;
265        exercise_services_by_type.insert(exercise_service.slug.clone(), (exercise_service, info));
266    }
267    Ok(exercise_services_by_type)
268}
269
270pub async fn get_upsert_service_info_by_exercise_service(
271    conn: &mut PgConnection,
272    exercise_service: &ExerciseService,
273    fetch_service_info: impl Fn(Url) -> BoxFuture<'static, ModelResult<ExerciseServiceInfoApi>>,
274) -> ModelResult<ExerciseServiceInfo> {
275    let res = get_service_info(conn, exercise_service.id).await;
276    let service_info = match res {
277        Ok(exercise_service_info) => exercise_service_info,
278        _ => {
279            warn!(
280                "Could not find service info for {} ({}). This is rare and only should happen when a background worker has not had the opportunity to complete their fetching task yet. Trying the fetching here in this worker so that we can continue.",
281                exercise_service.name, exercise_service.slug
282            );
283
284            fetch_and_upsert_service_info(conn, exercise_service, fetch_service_info).await?
285        }
286    };
287    Ok(service_info)
288}
289
290pub async fn get_service_info_by_exercise_service(
291    conn: &mut PgConnection,
292    exercise_service: &ExerciseService,
293) -> ModelResult<Option<ExerciseServiceInfo>> {
294    let res = get_service_info(conn, exercise_service.id).await;
295    let service_info = match res {
296        Ok(exercise_service_info) => exercise_service_info,
297        _ => {
298            warn!(
299                "Could not find service info for {} ({}). This is rare and only should happen when a background worker has not had the opportunity to complete their fetching task yet.",
300                exercise_service.name, exercise_service.slug
301            );
302            return Ok(None);
303        }
304    };
305    Ok(Some(service_info))
306}
307
308/**
309Returns service info meant for the course material. If no service info is found and fetching it fails, we return None to
310indicate that the service info is unavailable.
311*/
312pub async fn get_course_material_service_info_by_exercise_type(
313    conn: &mut PgConnection,
314    exercise_type: &str,
315    fetch_service_info: impl Fn(Url) -> BoxFuture<'static, ModelResult<ExerciseServiceInfoApi>>,
316) -> ModelResult<Option<CourseMaterialExerciseServiceInfo>> {
317    match get_exercise_service_by_exercise_type(conn, exercise_type).await {
318        Ok(exercise_service) => {
319            let full_service_info = get_upsert_service_info_by_exercise_service(
320                conn,
321                &exercise_service,
322                fetch_service_info,
323            )
324            .await;
325            let service_info_option = match full_service_info {
326                Ok(o) => {
327                    // Need to convert relative url to absolute url because
328                    // otherwise the material won't be able to request the path
329                    // if the path is in a different domain
330                    let mut url =
331                        Url::parse(&exercise_service.public_url).map_err(|original_err| {
332                            ModelError::new(
333                                ModelErrorType::Generic,
334                                original_err.to_string(),
335                                Some(original_err.into()),
336                            )
337                        })?;
338                    url.set_path(&o.user_interface_iframe_path);
339                    url.set_query(None);
340                    url.set_fragment(None);
341
342                    Some(CourseMaterialExerciseServiceInfo {
343                        exercise_iframe_url: url.to_string(),
344                    })
345                }
346                _ => None,
347            };
348
349            Ok(service_info_option)
350        }
351        _ => Ok(None),
352    }
353}