headless_lms_models/
exercise_services.rs

1use futures::future::BoxFuture;
2use url::Url;
3
4use crate::{
5    exercise_service_info::{
6        ExerciseServiceInfo, ExerciseServiceInfoApi, get_all_exercise_services_by_type,
7    },
8    prelude::*,
9};
10
11#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
12#[cfg_attr(feature = "ts_rs", derive(TS))]
13pub struct ExerciseService {
14    pub id: Uuid,
15    pub created_at: DateTime<Utc>,
16    pub updated_at: DateTime<Utc>,
17    pub deleted_at: Option<DateTime<Utc>>,
18    pub name: String,
19    pub slug: String,
20    pub public_url: String,
21    /// 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.
22    pub internal_url: Option<String>,
23    pub max_reprocessing_submissions_at_once: i32,
24}
25
26/// Exercise service definition that the CMS can use to render the editor view.
27#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
28#[cfg_attr(feature = "ts_rs", derive(TS))]
29pub struct ExerciseServiceIframeRenderingInfo {
30    pub id: Uuid,
31    pub name: String,
32    pub slug: String,
33    pub public_iframe_url: String,
34    // #[serde(skip_serializing_if = "Option::is_none")]
35    pub has_custom_view: bool,
36}
37
38#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
39#[cfg_attr(feature = "ts_rs", derive(TS))]
40pub struct ExerciseServiceNewOrUpdate {
41    pub name: String,
42    pub slug: String,
43    pub public_url: String,
44    pub internal_url: Option<String>,
45    pub max_reprocessing_submissions_at_once: i32,
46}
47
48pub async fn get_exercise_service(
49    conn: &mut PgConnection,
50    id: Uuid,
51) -> ModelResult<ExerciseService> {
52    let res = sqlx::query_as!(
53        ExerciseService,
54        r#"
55SELECT *
56FROM exercise_services
57WHERE id = $1
58  "#,
59        id
60    )
61    .fetch_one(conn)
62    .await?;
63    Ok(res)
64}
65
66pub async fn update_exercise_service(
67    conn: &mut PgConnection,
68    id: Uuid,
69    exercise_service_update: &ExerciseServiceNewOrUpdate,
70) -> ModelResult<ExerciseService> {
71    let res = sqlx::query_as!(
72        ExerciseService,
73        r#"
74UPDATE exercise_services
75    SET name=$1, slug=$2, public_url=$3, internal_url=$4, max_reprocessing_submissions_at_once=$5
76WHERE id=$6
77    RETURNING *
78        "#,
79        exercise_service_update.name,
80        exercise_service_update.slug,
81        exercise_service_update.public_url,
82        exercise_service_update.internal_url,
83        exercise_service_update.max_reprocessing_submissions_at_once,
84        id
85    )
86    .fetch_one(conn)
87    .await?;
88    Ok(res)
89}
90
91pub async fn delete_exercise_service(
92    conn: &mut PgConnection,
93    id: Uuid,
94) -> ModelResult<ExerciseService> {
95    let deleted = sqlx::query_as!(
96        ExerciseService,
97        r#"
98UPDATE exercise_services
99    SET deleted_at = now()
100WHERE id = $1
101    RETURNING *
102        "#,
103        id
104    )
105    .fetch_one(conn)
106    .await?;
107    Ok(deleted)
108}
109
110pub async fn get_exercise_service_by_exercise_type(
111    conn: &mut PgConnection,
112    exercise_type: &str,
113) -> ModelResult<ExerciseService> {
114    let res = sqlx::query_as!(
115        ExerciseService,
116        r#"
117SELECT *
118FROM exercise_services
119WHERE slug = $1
120AND deleted_at IS NULL
121  "#,
122        exercise_type
123    )
124    .fetch_one(conn)
125    .await?;
126    Ok(res)
127}
128
129pub async fn get_exercise_service_internally_preferred_baseurl_by_exercise_type(
130    conn: &mut PgConnection,
131    exercise_type: &str,
132) -> ModelResult<Url> {
133    let exercise_service = get_exercise_service_by_exercise_type(conn, exercise_type).await?;
134    get_exercise_service_internally_preferred_baseurl(&exercise_service)
135}
136
137pub fn get_exercise_service_internally_preferred_baseurl(
138    exercise_service: &ExerciseService,
139) -> ModelResult<Url> {
140    let stored_url_str = exercise_service
141        .internal_url
142        .as_ref()
143        .unwrap_or(&exercise_service.public_url);
144    let mut url = Url::parse(stored_url_str).map_err(|original_error| {
145        ModelError::new(
146            ModelErrorType::Generic,
147            original_error.to_string(),
148            Some(original_error.into()),
149        )
150    })?;
151    // remove the path because all relative urls in service info assume
152    // that the base url prefix has no path
153    url.set_path("");
154    Ok(url)
155}
156
157pub fn get_exercise_service_externally_preferred_baseurl(
158    exercise_service: &ExerciseService,
159) -> ModelResult<Url> {
160    let stored_url_str = &exercise_service.public_url;
161    let mut url = Url::parse(stored_url_str).map_err(|original_error| {
162        ModelError::new(
163            ModelErrorType::Generic,
164            original_error.to_string(),
165            Some(original_error.into()),
166        )
167    })?;
168    // remove the path because all relative urls in service info assume
169    // that the base url prefix has no path
170    url.set_path("");
171    Ok(url)
172}
173
174/**
175Returns a url that can be used to grade a submission for this exercise service.
176*/
177pub async fn get_internal_grade_url(
178    exercise_service: &ExerciseService,
179    exercise_service_info: &ExerciseServiceInfo,
180) -> ModelResult<Url> {
181    let mut url = get_exercise_service_internally_preferred_baseurl(exercise_service)?;
182    url.set_path(&exercise_service_info.grade_endpoint_path);
183    Ok(url)
184}
185
186/**
187Returns a url that can be used to generate a public version of a private spec.
188*/
189pub fn get_internal_public_spec_url(
190    exercise_service: &ExerciseService,
191    exercise_service_info: &ExerciseServiceInfo,
192) -> ModelResult<Url> {
193    let mut url = get_exercise_service_internally_preferred_baseurl(exercise_service)?;
194    url.set_path(&exercise_service_info.public_spec_endpoint_path);
195    Ok(url)
196}
197
198pub fn get_model_solution_url(
199    exercise_service: &ExerciseService,
200    exercise_service_info: &ExerciseServiceInfo,
201) -> ModelResult<Url> {
202    let mut url = get_exercise_service_internally_preferred_baseurl(exercise_service)?;
203    url.set_path(&exercise_service_info.model_solution_spec_endpoint_path);
204    Ok(url)
205}
206
207pub async fn get_exercise_services(conn: &mut PgConnection) -> ModelResult<Vec<ExerciseService>> {
208    let res = sqlx::query_as!(
209        ExerciseService,
210        r#"
211SELECT *
212FROM exercise_services
213WHERE deleted_at IS NULL
214"#
215    )
216    .fetch_all(conn)
217    .await?;
218    Ok(res)
219}
220
221pub async fn get_all_exercise_services_iframe_rendering_infos(
222    conn: &mut PgConnection,
223    fetch_service_info: impl Fn(Url) -> BoxFuture<'static, ModelResult<ExerciseServiceInfoApi>>,
224) -> ModelResult<Vec<ExerciseServiceIframeRenderingInfo>> {
225    let services = get_exercise_services(conn).await?;
226    let service_infos = get_all_exercise_services_by_type(conn, fetch_service_info).await?;
227    let res = services
228        .into_iter()
229        .filter_map(|exercise_service| {
230            if let Some((_, service_info)) = service_infos.get(&exercise_service.slug) {
231                match get_exercise_service_externally_preferred_baseurl(&exercise_service) { Ok(mut url) => {
232                    url.set_path(&service_info.user_interface_iframe_path);
233                    Some(ExerciseServiceIframeRenderingInfo {
234                        id: exercise_service.id,
235                        name: exercise_service.name,
236                        slug: exercise_service.slug,
237                        public_iframe_url: url.to_string(),
238                        has_custom_view: service_info.has_custom_view,
239                    })
240                } _ => {
241                    warn!(exercise_service_id = ?exercise_service.id, "Skipping exercise service from the list because it has an invalid base url");
242                    None
243                }}
244
245            } else {
246                warn!(exercise_service_id = ?exercise_service.id, "Skipping exercise service from the list because it doesn't have a service info");
247                None
248            }
249        })
250        .collect::<Vec<_>>();
251    Ok(res)
252}
253
254pub async fn insert_exercise_service(
255    conn: &mut PgConnection,
256    exercise_service_update: &ExerciseServiceNewOrUpdate,
257) -> ModelResult<ExerciseService> {
258    let res = sqlx::query_as!(
259        ExerciseService,
260        r#"
261INSERT INTO exercise_services (
262    name,
263    slug,
264    public_url,
265    internal_url,
266    max_reprocessing_submissions_at_once
267  )
268VALUES ($1, $2, $3, $4, $5)
269RETURNING *
270  "#,
271        exercise_service_update.name,
272        exercise_service_update.slug,
273        exercise_service_update.public_url,
274        exercise_service_update.internal_url,
275        exercise_service_update.max_reprocessing_submissions_at_once
276    )
277    .fetch_one(conn)
278    .await?;
279    Ok(res)
280}