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