headless_lms_models/
exercise_service_info.rs

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