Skip to main content

headless_lms_models/
exercise_services.rs

1use url::Url;
2use utoipa::ToSchema;
3
4use crate::{
5    exercise_service_info::{ExerciseServiceInfo, get_all_exercise_services_by_type},
6    prelude::*,
7};
8
9#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
10
11pub struct ExerciseService {
12    pub id: Uuid,
13    pub created_at: DateTime<Utc>,
14    pub updated_at: DateTime<Utc>,
15    pub deleted_at: Option<DateTime<Utc>>,
16    pub name: String,
17    pub slug: String,
18    pub public_url: String,
19    /// This is needed because connecting to services directly inside the cluster with a special url is much for efficient than connecting to the same service with a url that would get routed though the internet. If not defined, use we can reach the service with the public url.
20    pub internal_url: Option<String>,
21    pub max_reprocessing_submissions_at_once: i32,
22}
23
24/// Exercise service definition that the CMS can use to render the editor view.
25#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
26
27pub struct ExerciseServiceIframeRenderingInfo {
28    pub id: Uuid,
29    pub name: String,
30    pub slug: String,
31    pub public_iframe_url: String,
32    // #[serde(skip_serializing_if = "Option::is_none")]
33    pub has_custom_view: bool,
34}
35
36#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
37
38pub struct ExerciseServiceNewOrUpdate {
39    pub name: String,
40    pub slug: String,
41    pub public_url: String,
42    pub internal_url: Option<String>,
43    pub max_reprocessing_submissions_at_once: i32,
44}
45
46pub async fn get_exercise_service(
47    conn: &mut PgConnection,
48    id: Uuid,
49) -> ModelResult<ExerciseService> {
50    let res = sqlx::query_as!(
51        ExerciseService,
52        r#"
53SELECT *
54FROM exercise_services
55WHERE id = $1
56  "#,
57        id
58    )
59    .fetch_one(conn)
60    .await?;
61    Ok(res)
62}
63
64pub async fn update_exercise_service(
65    conn: &mut PgConnection,
66    id: Uuid,
67    exercise_service_update: &ExerciseServiceNewOrUpdate,
68) -> ModelResult<ExerciseService> {
69    let res = sqlx::query_as!(
70        ExerciseService,
71        r#"
72UPDATE exercise_services
73    SET name=$1, slug=$2, public_url=$3, internal_url=$4, max_reprocessing_submissions_at_once=$5
74WHERE id=$6
75    RETURNING *
76        "#,
77        exercise_service_update.name,
78        exercise_service_update.slug,
79        exercise_service_update.public_url,
80        exercise_service_update.internal_url,
81        exercise_service_update.max_reprocessing_submissions_at_once,
82        id
83    )
84    .fetch_one(conn)
85    .await?;
86    Ok(res)
87}
88
89pub async fn delete_exercise_service(
90    conn: &mut PgConnection,
91    id: Uuid,
92) -> ModelResult<ExerciseService> {
93    let deleted = sqlx::query_as!(
94        ExerciseService,
95        r#"
96UPDATE exercise_services
97    SET deleted_at = now()
98WHERE id = $1
99AND deleted_at IS NULL
100    RETURNING *
101        "#,
102        id
103    )
104    .fetch_one(conn)
105    .await?;
106    Ok(deleted)
107}
108
109pub async fn get_exercise_service_by_exercise_type(
110    conn: &mut PgConnection,
111    exercise_type: &str,
112) -> ModelResult<ExerciseService> {
113    let res = sqlx::query_as!(
114        ExerciseService,
115        r#"
116SELECT *
117FROM exercise_services
118WHERE slug = $1
119AND deleted_at IS NULL
120  "#,
121        exercise_type
122    )
123    .fetch_one(conn)
124    .await?;
125    Ok(res)
126}
127
128pub async fn get_exercise_service_internally_preferred_baseurl_by_exercise_type(
129    conn: &mut PgConnection,
130    exercise_type: &str,
131) -> ModelResult<Url> {
132    let exercise_service = get_exercise_service_by_exercise_type(conn, exercise_type).await?;
133    get_exercise_service_internally_preferred_baseurl(&exercise_service)
134}
135
136pub fn get_exercise_service_internally_preferred_baseurl(
137    exercise_service: &ExerciseService,
138) -> ModelResult<Url> {
139    let stored_url_str = exercise_service
140        .internal_url
141        .as_ref()
142        .unwrap_or(&exercise_service.public_url);
143    let mut url = Url::parse(stored_url_str).map_err(|original_error| {
144        ModelError::new(
145            ModelErrorType::Generic,
146            original_error.to_string(),
147            Some(original_error.into()),
148        )
149    })?;
150    // remove the path because all relative urls in service info assume
151    // that the base url prefix has no path
152    url.set_path("");
153    Ok(url)
154}
155
156pub fn get_exercise_service_externally_preferred_baseurl(
157    exercise_service: &ExerciseService,
158) -> ModelResult<Url> {
159    let stored_url_str = &exercise_service.public_url;
160    let mut url = Url::parse(stored_url_str).map_err(|original_error| {
161        ModelError::new(
162            ModelErrorType::Generic,
163            original_error.to_string(),
164            Some(original_error.into()),
165        )
166    })?;
167    // remove the path because all relative urls in service info assume
168    // that the base url prefix has no path
169    url.set_path("");
170    Ok(url)
171}
172
173/**
174Returns a url that can be used to grade a submission for this exercise service.
175*/
176pub async fn get_internal_grade_url(
177    exercise_service: &ExerciseService,
178    exercise_service_info: &ExerciseServiceInfo,
179) -> ModelResult<Url> {
180    let mut url = get_exercise_service_internally_preferred_baseurl(exercise_service)?;
181    url.set_path(&exercise_service_info.grade_endpoint_path);
182    Ok(url)
183}
184
185/**
186Returns a url that can be used to generate a public version of a private spec.
187*/
188pub fn get_internal_public_spec_url(
189    exercise_service: &ExerciseService,
190    exercise_service_info: &ExerciseServiceInfo,
191) -> ModelResult<Url> {
192    let mut url = get_exercise_service_internally_preferred_baseurl(exercise_service)?;
193    url.set_path(&exercise_service_info.public_spec_endpoint_path);
194    Ok(url)
195}
196
197/**
198Slugs of the exercise services that declare they can serve a native (non-browser) client.
199
200Reads the `exercise_service_info` cache that `service-info-fetcher` refreshes about once a
201minute. Callers must not fetch service info live instead: that would fan a single request
202out to every exercise service.
203*/
204pub async fn get_native_client_capable_slugs(conn: &mut PgConnection) -> ModelResult<Vec<String>> {
205    let res = sqlx::query_scalar!(
206        r#"
207SELECT es.slug
208FROM exercise_services AS es
209  JOIN exercise_service_info AS esi ON esi.exercise_service_id = es.id
210WHERE es.deleted_at IS NULL
211  AND esi.supports_native_client
212"#
213    )
214    .fetch_all(conn)
215    .await?;
216    Ok(res)
217}
218
219pub fn get_model_solution_url(
220    exercise_service: &ExerciseService,
221    exercise_service_info: &ExerciseServiceInfo,
222) -> ModelResult<Url> {
223    let mut url = get_exercise_service_internally_preferred_baseurl(exercise_service)?;
224    url.set_path(&exercise_service_info.model_solution_spec_endpoint_path);
225    Ok(url)
226}
227
228pub async fn get_exercise_services(conn: &mut PgConnection) -> ModelResult<Vec<ExerciseService>> {
229    let res = sqlx::query_as!(
230        ExerciseService,
231        r#"
232SELECT *
233FROM exercise_services
234WHERE deleted_at IS NULL
235"#
236    )
237    .fetch_all(conn)
238    .await?;
239    Ok(res)
240}
241
242pub async fn get_all_exercise_services_iframe_rendering_infos(
243    conn: &mut PgConnection,
244) -> ModelResult<Vec<ExerciseServiceIframeRenderingInfo>> {
245    let services = get_exercise_services(conn).await?;
246    let service_infos = get_all_exercise_services_by_type(conn).await?;
247    let res = services
248        .into_iter()
249        .filter_map(|exercise_service| {
250            if let Some((_, service_info)) = service_infos.get(&exercise_service.slug) {
251                match get_exercise_service_externally_preferred_baseurl(&exercise_service) { Ok(mut url) => {
252                    url.set_path(&service_info.user_interface_iframe_path);
253                    Some(ExerciseServiceIframeRenderingInfo {
254                        id: exercise_service.id,
255                        name: exercise_service.name,
256                        slug: exercise_service.slug,
257                        public_iframe_url: url.to_string(),
258                        has_custom_view: service_info.has_custom_view,
259                    })
260                } _ => {
261                    warn!(exercise_service_id = ?exercise_service.id, "Skipping exercise service from the list because it has an invalid base url");
262                    None
263                }}
264
265            } else {
266                warn!(exercise_service_id = ?exercise_service.id, "Skipping exercise service from the list because it doesn't have a service info");
267                None
268            }
269        })
270        .collect::<Vec<_>>();
271    Ok(res)
272}
273
274pub async fn insert_exercise_service(
275    conn: &mut PgConnection,
276    exercise_service_update: &ExerciseServiceNewOrUpdate,
277) -> ModelResult<ExerciseService> {
278    let res = sqlx::query_as!(
279        ExerciseService,
280        r#"
281INSERT INTO exercise_services (
282    name,
283    slug,
284    public_url,
285    internal_url,
286    max_reprocessing_submissions_at_once
287  )
288VALUES ($1, $2, $3, $4, $5)
289RETURNING *
290  "#,
291        exercise_service_update.name,
292        exercise_service_update.slug,
293        exercise_service_update.public_url,
294        exercise_service_update.internal_url,
295        exercise_service_update.max_reprocessing_submissions_at_once
296    )
297    .fetch_one(conn)
298    .await?;
299    Ok(res)
300}
301
302#[cfg(test)]
303mod test {
304    use super::*;
305    use crate::exercise_service_info::{self, ExerciseServiceInfo, PathInfo};
306    use crate::test_helper::*;
307
308    async fn insert_service(
309        tx: &mut PgConnection,
310        slug: &str,
311        supports_native_client: bool,
312    ) -> (ExerciseService, ExerciseServiceInfo) {
313        let service = insert_exercise_service(
314            tx,
315            &ExerciseServiceNewOrUpdate {
316                name: slug.to_string(),
317                slug: slug.to_string(),
318                public_url: "https://example.com".to_string(),
319                internal_url: Some("http://internal.example.com".to_string()),
320                max_reprocessing_submissions_at_once: 1,
321            },
322        )
323        .await
324        .unwrap();
325        let info = exercise_service_info::insert(
326            tx,
327            &PathInfo {
328                exercise_service_id: service.id,
329                user_interface_iframe_path: "/iframe".to_string(),
330                grade_endpoint_path: "/grade".to_string(),
331                public_spec_endpoint_path: "/public-spec".to_string(),
332                model_solution_spec_endpoint_path: "/model-solution".to_string(),
333                has_custom_view: false,
334                supports_native_client,
335                produces_file_answers: false,
336                declares_spec_files: false,
337            },
338        )
339        .await
340        .unwrap();
341        (service, info)
342    }
343
344    #[tokio::test]
345    async fn only_services_declaring_native_client_support_are_native_client_capable() {
346        insert_data!(:tx);
347        insert_service(tx.as_mut(), "capable", true).await;
348        insert_service(tx.as_mut(), "not-capable", false).await;
349
350        // Asserted by membership rather than equality: a seeded database has services of its own.
351        let slugs = get_native_client_capable_slugs(tx.as_mut()).await.unwrap();
352        assert!(slugs.contains(&"capable".to_string()), "{slugs:?}");
353        assert!(!slugs.contains(&"not-capable".to_string()), "{slugs:?}");
354        tx.rollback().await;
355    }
356
357    /// The gate reads what a service declares in its own service info, so a service that starts
358    /// declaring native-client support becomes capable with no other change.
359    #[tokio::test]
360    async fn declaring_native_client_support_in_service_info_makes_a_service_capable() {
361        insert_data!(:tx);
362        let (service, info) = insert_service(tx.as_mut(), "late-declarer", false).await;
363        assert!(
364            !get_native_client_capable_slugs(tx.as_mut())
365                .await
366                .unwrap()
367                .contains(&"late-declarer".to_string())
368        );
369
370        exercise_service_info::upsert_service_info(
371            tx.as_mut(),
372            service.id,
373            &exercise_service_info::ExerciseServiceInfoApi {
374                service_name: "late-declarer".to_string(),
375                user_interface_iframe_path: info.user_interface_iframe_path.clone(),
376                grade_endpoint_path: info.grade_endpoint_path.clone(),
377                public_spec_endpoint_path: info.public_spec_endpoint_path.clone(),
378                model_solution_spec_endpoint_path: info.model_solution_spec_endpoint_path.clone(),
379                has_custom_view: Some(false),
380                csv_export_definitions_endpoint_path: None,
381                csv_export_answers_endpoint_path: None,
382                supports_native_client: true,
383                produces_file_answers: false,
384                declares_spec_files: false,
385            },
386        )
387        .await
388        .unwrap();
389        assert!(
390            get_native_client_capable_slugs(tx.as_mut())
391                .await
392                .unwrap()
393                .contains(&"late-declarer".to_string())
394        );
395        tx.rollback().await;
396    }
397
398    #[tokio::test]
399    async fn a_deleted_service_is_not_native_client_capable() {
400        insert_data!(:tx);
401        let (service, _) = insert_service(tx.as_mut(), "deleted-capable", true).await;
402        delete_exercise_service(tx.as_mut(), service.id)
403            .await
404            .unwrap();
405        assert!(
406            !get_native_client_capable_slugs(tx.as_mut())
407                .await
408                .unwrap()
409                .contains(&"deleted-capable".to_string())
410        );
411        tx.rollback().await;
412    }
413
414    #[tokio::test]
415    async fn a_service_without_service_info_is_not_native_client_capable() {
416        insert_data!(:tx);
417        insert_exercise_service(
418            tx.as_mut(),
419            &ExerciseServiceNewOrUpdate {
420                name: "no-info".to_string(),
421                slug: "no-info".to_string(),
422                public_url: "https://example.com".to_string(),
423                internal_url: None,
424                max_reprocessing_submissions_at_once: 1,
425            },
426        )
427        .await
428        .unwrap();
429        assert!(
430            !get_native_client_capable_slugs(tx.as_mut())
431                .await
432                .unwrap()
433                .contains(&"no-info".to_string())
434        );
435        tx.rollback().await;
436    }
437}