Skip to main content

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    pub supports_native_client: bool,
28    pub produces_file_answers: bool,
29    pub declares_spec_files: bool,
30}
31
32#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
33pub struct PathInfo {
34    pub exercise_service_id: Uuid,
35    pub user_interface_iframe_path: String,
36    pub grade_endpoint_path: String,
37    pub public_spec_endpoint_path: String,
38    pub model_solution_spec_endpoint_path: String,
39    // #[serde(skip_serializing_if = "Option::is_none")]
40    pub has_custom_view: bool,
41    pub supports_native_client: bool,
42    pub produces_file_answers: bool,
43    pub declares_spec_files: bool,
44}
45
46#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
47
48pub struct CourseMaterialExerciseServiceInfo {
49    pub exercise_iframe_url: String,
50}
51
52#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
53
54pub struct ExerciseServiceInfoApi {
55    pub service_name: String,
56    pub user_interface_iframe_path: String,
57    pub grade_endpoint_path: String,
58    pub public_spec_endpoint_path: String,
59    pub model_solution_spec_endpoint_path: String,
60    #[serde(skip_serializing_if = "Option::is_none")]
61    pub has_custom_view: Option<bool>,
62    #[serde(skip_serializing_if = "Option::is_none")]
63    pub csv_export_definitions_endpoint_path: Option<String>,
64    #[serde(skip_serializing_if = "Option::is_none")]
65    pub csv_export_answers_endpoint_path: Option<String>,
66    /// Whether the service can be answered from a native (non-browser) client. Declaring it is
67    /// what makes the service visible to the exercise-services client API.
68    #[serde(default)]
69    pub supports_native_client: bool,
70    /// Whether this service's answers consist of uploaded files rather than JSON. Unrelated to
71    /// `supports_native_client`, which is about the client that answers, not the answer's shape.
72    #[serde(default)]
73    pub produces_file_answers: bool,
74    /// Whether this service declares which stored files its specs reference: the private spec's in
75    /// the editor's `current-state` message, and each derived spec's in the response of the
76    /// endpoint that produced it, which then returns `{ spec, files }` instead of the bare spec.
77    ///
78    /// Declaring is what lets the host reclaim the service's abandoned uploads. A service that does
79    /// not declare keeps every file it ever uploaded, because the host cannot read a spec and so
80    /// has no evidence that a file is unused.
81    #[serde(default)]
82    pub declares_spec_files: bool,
83}
84
85pub async fn insert(
86    conn: &mut PgConnection,
87    exercise_service_info: &PathInfo,
88) -> ModelResult<ExerciseServiceInfo> {
89    let res = sqlx::query_as!(
90        ExerciseServiceInfo,
91        "
92INSERT INTO exercise_service_info (
93    exercise_service_id,
94    user_interface_iframe_path,
95    grade_endpoint_path,
96    public_spec_endpoint_path,
97    model_solution_spec_endpoint_path,
98    has_custom_view,
99    supports_native_client,
100    produces_file_answers,
101    declares_spec_files
102  )
103VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
104RETURNING *
105",
106        exercise_service_info.exercise_service_id,
107        exercise_service_info.user_interface_iframe_path,
108        exercise_service_info.grade_endpoint_path,
109        exercise_service_info.public_spec_endpoint_path,
110        exercise_service_info.model_solution_spec_endpoint_path,
111        exercise_service_info.has_custom_view,
112        exercise_service_info.supports_native_client,
113        exercise_service_info.produces_file_answers,
114        exercise_service_info.declares_spec_files
115    )
116    .fetch_one(conn)
117    .await?;
118    Ok(res)
119}
120
121pub async fn fetch_and_upsert_service_info(
122    conn: &mut PgConnection,
123    exercise_service: &ExerciseService,
124    fetch_service_info: impl Fn(Url) -> BoxFuture<'static, ModelResult<ExerciseServiceInfoApi>>,
125) -> ModelResult<ExerciseServiceInfo> {
126    let url = match exercise_service
127        .internal_url
128        .clone()
129        .map(|url| Url::parse(&url))
130    {
131        Some(Ok(url)) => url.to_string(),
132
133        Some(Err(e)) => {
134            warn!(
135                "Internal_url provided for {} is not a valid url. Using public_url instead. Error: {}",
136                exercise_service.name,
137                e.to_string()
138            );
139            exercise_service.public_url.clone()
140        }
141        None => exercise_service.public_url.clone(),
142    };
143    let fetched_info = fetch_service_info(url.parse()?).await?;
144    let res = upsert_service_info(conn, exercise_service.id, &fetched_info).await?;
145    Ok(res)
146}
147
148pub async fn upsert_service_info(
149    conn: &mut PgConnection,
150    exercise_service_id: Uuid,
151    update: &ExerciseServiceInfoApi,
152) -> ModelResult<ExerciseServiceInfo> {
153    let res = sqlx::query_as!(
154        ExerciseServiceInfo,
155        r#"
156INSERT INTO exercise_service_info(
157    exercise_service_id,
158    user_interface_iframe_path,
159    grade_endpoint_path,
160    public_spec_endpoint_path,
161    model_solution_spec_endpoint_path,
162    has_custom_view,
163    csv_export_definitions_endpoint_path,
164    csv_export_answers_endpoint_path,
165    supports_native_client,
166    produces_file_answers,
167    declares_spec_files
168  )
169VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
170ON CONFLICT(exercise_service_id) DO UPDATE
171SET user_interface_iframe_path = $2,
172  grade_endpoint_path = $3,
173  public_spec_endpoint_path = $4,
174  model_solution_spec_endpoint_path = $5,
175  has_custom_view = $6,
176  csv_export_definitions_endpoint_path = $7,
177  csv_export_answers_endpoint_path = $8,
178  supports_native_client = $9,
179  produces_file_answers = $10,
180  declares_spec_files = $11
181RETURNING *
182    "#,
183        exercise_service_id,
184        update.user_interface_iframe_path,
185        update.grade_endpoint_path,
186        update.public_spec_endpoint_path,
187        update.model_solution_spec_endpoint_path,
188        update.has_custom_view.unwrap_or_else(|| false),
189        update.csv_export_definitions_endpoint_path.as_deref(),
190        update.csv_export_answers_endpoint_path.as_deref(),
191        update.supports_native_client,
192        update.produces_file_answers,
193        update.declares_spec_files
194    )
195    .fetch_one(conn)
196    .await?;
197    Ok(res)
198}
199
200pub async fn get_service_info(
201    conn: &mut PgConnection,
202    exercise_service_id: Uuid,
203) -> ModelResult<ExerciseServiceInfo> {
204    let res = sqlx::query_as!(
205        ExerciseServiceInfo,
206        r#"
207SELECT *
208FROM exercise_service_info
209WHERE exercise_service_id = $1
210    "#,
211        exercise_service_id
212    )
213    .fetch_one(conn)
214    .await?;
215    Ok(res)
216}
217
218pub async fn get_service_info_by_exercise_type(
219    conn: &mut PgConnection,
220    exercise_type: &str,
221    fetch_service_info: impl Fn(Url) -> BoxFuture<'static, ModelResult<ExerciseServiceInfoApi>>,
222) -> ModelResult<ExerciseServiceInfo> {
223    let exercise_service = get_exercise_service_by_exercise_type(conn, exercise_type).await?;
224    let service_info =
225        get_upsert_service_info_by_exercise_service(conn, &exercise_service, fetch_service_info)
226            .await?;
227    Ok(service_info)
228}
229
230pub async fn get_all_exercise_services_by_type(
231    conn: &mut PgConnection,
232) -> ModelResult<HashMap<String, (ExerciseService, ExerciseServiceInfo)>> {
233    let mut exercise_services_by_type = HashMap::new();
234    for exercise_service in get_exercise_services(conn).await? {
235        match get_service_info_by_exercise_service(conn, &exercise_service).await {
236            Ok(Some(info)) => {
237                exercise_services_by_type
238                    .insert(exercise_service.slug.clone(), (exercise_service, info));
239            }
240            _ => {
241                tracing::error!(
242                    "No corresponding service info found for {} ({})",
243                    exercise_service.name,
244                    exercise_service.id
245                );
246            }
247        }
248    }
249    Ok(exercise_services_by_type)
250}
251
252pub async fn get_upsert_all_exercise_services_by_type(
253    conn: &mut PgConnection,
254    fetch_service_info: impl Fn(Url) -> BoxFuture<'static, ModelResult<ExerciseServiceInfoApi>>,
255) -> ModelResult<HashMap<String, (ExerciseService, ExerciseServiceInfo)>> {
256    let mut exercise_services_by_type = HashMap::new();
257    for exercise_service in get_exercise_services(conn).await? {
258        match get_upsert_service_info_by_exercise_service(
259            conn,
260            &exercise_service,
261            &fetch_service_info,
262        )
263        .await
264        {
265            Ok(info) => {
266                exercise_services_by_type
267                    .insert(exercise_service.slug.clone(), (exercise_service, info));
268            }
269            _ => {
270                tracing::error!(
271                    "No corresponding service info found for {} ({})",
272                    exercise_service.name,
273                    exercise_service.id
274                );
275            }
276        }
277    }
278    Ok(exercise_services_by_type)
279}
280
281pub async fn get_selected_exercise_services_by_type(
282    conn: &mut PgConnection,
283    slugs: &[String],
284    fetch_service_info: impl Fn(Url) -> BoxFuture<'static, ModelResult<ExerciseServiceInfoApi>>,
285) -> ModelResult<HashMap<String, (ExerciseService, ExerciseServiceInfo)>> {
286    let selected_services = sqlx::query_as!(
287        ExerciseService,
288        "
289SELECT *
290FROM exercise_services
291WHERE slug = ANY($1);",
292        slugs,
293    )
294    .fetch_all(&mut *conn)
295    .await?;
296    let mut exercise_services_by_type = HashMap::new();
297    for exercise_service in selected_services {
298        let info = get_upsert_service_info_by_exercise_service(
299            conn,
300            &exercise_service,
301            &fetch_service_info,
302        )
303        .await?;
304        exercise_services_by_type.insert(exercise_service.slug.clone(), (exercise_service, info));
305    }
306    Ok(exercise_services_by_type)
307}
308
309pub async fn get_upsert_service_info_by_exercise_service(
310    conn: &mut PgConnection,
311    exercise_service: &ExerciseService,
312    fetch_service_info: impl Fn(Url) -> BoxFuture<'static, ModelResult<ExerciseServiceInfoApi>>,
313) -> ModelResult<ExerciseServiceInfo> {
314    let res = get_service_info(conn, exercise_service.id).await;
315    let service_info = match res {
316        Ok(exercise_service_info) => exercise_service_info,
317        _ => {
318            warn!(
319                "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.",
320                exercise_service.name, exercise_service.slug
321            );
322
323            fetch_and_upsert_service_info(conn, exercise_service, fetch_service_info).await?
324        }
325    };
326    Ok(service_info)
327}
328
329pub async fn get_service_info_by_exercise_service(
330    conn: &mut PgConnection,
331    exercise_service: &ExerciseService,
332) -> ModelResult<Option<ExerciseServiceInfo>> {
333    let res = get_service_info(conn, exercise_service.id).await;
334    let service_info = match res {
335        Ok(exercise_service_info) => exercise_service_info,
336        _ => {
337            warn!(
338                "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.",
339                exercise_service.name, exercise_service.slug
340            );
341            return Ok(None);
342        }
343    };
344    Ok(Some(service_info))
345}
346
347/**
348Returns service info meant for the course material. If no service info is found and fetching it fails, we return None to
349indicate that the service info is unavailable.
350*/
351pub async fn get_course_material_service_info_by_exercise_type(
352    conn: &mut PgConnection,
353    exercise_type: &str,
354    fetch_service_info: impl Fn(Url) -> BoxFuture<'static, ModelResult<ExerciseServiceInfoApi>>,
355) -> ModelResult<Option<CourseMaterialExerciseServiceInfo>> {
356    match get_exercise_service_by_exercise_type(conn, exercise_type).await {
357        Ok(exercise_service) => {
358            let full_service_info = get_upsert_service_info_by_exercise_service(
359                conn,
360                &exercise_service,
361                fetch_service_info,
362            )
363            .await;
364            let service_info_option = match full_service_info {
365                Ok(o) => {
366                    // Need to convert relative url to absolute url because
367                    // otherwise the material won't be able to request the path
368                    // if the path is in a different domain
369                    let mut url =
370                        Url::parse(&exercise_service.public_url).map_err(|original_err| {
371                            ModelError::new(
372                                ModelErrorType::Generic,
373                                original_err.to_string(),
374                                Some(original_err.into()),
375                            )
376                        })?;
377                    url.set_path(&o.user_interface_iframe_path);
378                    url.set_query(None);
379                    url.set_fragment(None);
380
381                    Some(CourseMaterialExerciseServiceInfo {
382                        exercise_iframe_url: url.to_string(),
383                    })
384                }
385                _ => None,
386            };
387
388            Ok(service_info_option)
389        }
390        _ => Ok(None),
391    }
392}
393
394#[cfg(test)]
395mod test {
396    use super::*;
397    use crate::exercise_services::{ExerciseServiceNewOrUpdate, insert_exercise_service};
398    use crate::test_helper::*;
399
400    fn service_info_body(produces_file_answers: Option<bool>) -> serde_json::Value {
401        let mut body = serde_json::json!({
402            "service_name": "File answers",
403            "user_interface_iframe_path": "/iframe",
404            "grade_endpoint_path": "/grade",
405            "public_spec_endpoint_path": "/public-spec",
406            "model_solution_spec_endpoint_path": "/model-solution",
407        });
408        if let Some(declared) = produces_file_answers {
409            body["produces_file_answers"] = serde_json::json!(declared);
410        }
411        body
412    }
413
414    /// Services deployed before the field existed omit it, and their service info must still
415    /// deserialize instead of failing wholesale.
416    #[test]
417    fn a_service_info_body_declares_file_answers_or_defaults_to_not_producing_them() {
418        let omitted: ExerciseServiceInfoApi =
419            serde_json::from_value(service_info_body(None)).unwrap();
420        assert!(!omitted.produces_file_answers);
421
422        let declared: ExerciseServiceInfoApi =
423            serde_json::from_value(service_info_body(Some(true))).unwrap();
424        assert!(declared.produces_file_answers);
425    }
426
427    /// What a service declares is what the teacher UI offers an answer-file download for, and the
428    /// declaration only reaches it by being stored on the fetch hop.
429    #[tokio::test]
430    async fn a_declared_file_answer_capability_survives_the_fetch_and_store_hop() {
431        insert_data!(:tx);
432        let slug = format!("file-answers-{}", Uuid::new_v4());
433        let service = insert_exercise_service(
434            tx.as_mut(),
435            &ExerciseServiceNewOrUpdate {
436                name: slug.clone(),
437                slug: slug.clone(),
438                public_url: "http://example.com/api/service".to_string(),
439                internal_url: None,
440                max_reprocessing_submissions_at_once: 1,
441            },
442        )
443        .await
444        .unwrap();
445        let declared: ExerciseServiceInfoApi =
446            serde_json::from_value(service_info_body(Some(true))).unwrap();
447
448        let fetched = get_service_info_by_exercise_type(tx.as_mut(), &slug, |_url| {
449            let declared = declared.clone();
450            Box::pin(async move { Ok(declared) })
451        })
452        .await
453        .unwrap();
454
455        assert!(fetched.produces_file_answers);
456        assert!(
457            get_service_info(tx.as_mut(), service.id)
458                .await
459                .unwrap()
460                .produces_file_answers
461        );
462        tx.rollback().await;
463    }
464}