headless_lms_models/
exercise_service_info.rs

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