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_upsert_service_info_by_exercise_service(conn, &exercise_service, fetch_service_info)
175            .await?;
176    Ok(service_info)
177}
178
179pub async fn get_all_exercise_services_by_type(
180    conn: &mut PgConnection,
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).await {
185            Ok(Some(info)) => {
186                exercise_services_by_type
187                    .insert(exercise_service.slug.clone(), (exercise_service, info));
188            }
189            _ => {
190                tracing::error!(
191                    "No corresponding service info found for {} ({})",
192                    exercise_service.name,
193                    exercise_service.id
194                );
195            }
196        }
197    }
198    Ok(exercise_services_by_type)
199}
200
201pub async fn get_upsert_all_exercise_services_by_type(
202    conn: &mut PgConnection,
203    fetch_service_info: impl Fn(Url) -> BoxFuture<'static, ModelResult<ExerciseServiceInfoApi>>,
204) -> ModelResult<HashMap<String, (ExerciseService, ExerciseServiceInfo)>> {
205    let mut exercise_services_by_type = HashMap::new();
206    for exercise_service in get_exercise_services(conn).await? {
207        match get_upsert_service_info_by_exercise_service(
208            conn,
209            &exercise_service,
210            &fetch_service_info,
211        )
212        .await
213        {
214            Ok(info) => {
215                exercise_services_by_type
216                    .insert(exercise_service.slug.clone(), (exercise_service, info));
217            }
218            _ => {
219                tracing::error!(
220                    "No corresponding service info found for {} ({})",
221                    exercise_service.name,
222                    exercise_service.id
223                );
224            }
225        }
226    }
227    Ok(exercise_services_by_type)
228}
229
230pub async fn get_selected_exercise_services_by_type(
231    conn: &mut PgConnection,
232    slugs: &[String],
233    fetch_service_info: impl Fn(Url) -> BoxFuture<'static, ModelResult<ExerciseServiceInfoApi>>,
234) -> ModelResult<HashMap<String, (ExerciseService, ExerciseServiceInfo)>> {
235    let selected_services = sqlx::query_as!(
236        ExerciseService,
237        "
238SELECT *
239FROM exercise_services
240WHERE slug = ANY($1);",
241        slugs,
242    )
243    .fetch_all(&mut *conn)
244    .await?;
245    let mut exercise_services_by_type = HashMap::new();
246    for exercise_service in selected_services {
247        let info = get_upsert_service_info_by_exercise_service(
248            conn,
249            &exercise_service,
250            &fetch_service_info,
251        )
252        .await?;
253        exercise_services_by_type.insert(exercise_service.slug.clone(), (exercise_service, info));
254    }
255    Ok(exercise_services_by_type)
256}
257
258pub async fn get_upsert_service_info_by_exercise_service(
259    conn: &mut PgConnection,
260    exercise_service: &ExerciseService,
261    fetch_service_info: impl Fn(Url) -> BoxFuture<'static, ModelResult<ExerciseServiceInfoApi>>,
262) -> ModelResult<ExerciseServiceInfo> {
263    let res = get_service_info(conn, exercise_service.id).await;
264    let service_info = match res {
265        Ok(exercise_service_info) => exercise_service_info,
266        _ => {
267            warn!(
268                "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.",
269                exercise_service.name, exercise_service.slug
270            );
271
272            fetch_and_upsert_service_info(conn, exercise_service, fetch_service_info).await?
273        }
274    };
275    Ok(service_info)
276}
277
278pub async fn get_service_info_by_exercise_service(
279    conn: &mut PgConnection,
280    exercise_service: &ExerciseService,
281) -> ModelResult<Option<ExerciseServiceInfo>> {
282    let res = get_service_info(conn, exercise_service.id).await;
283    let service_info = match res {
284        Ok(exercise_service_info) => exercise_service_info,
285        _ => {
286            warn!(
287                "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.",
288                exercise_service.name, exercise_service.slug
289            );
290            return Ok(None);
291        }
292    };
293    Ok(Some(service_info))
294}
295
296/**
297Returns service info meant for the course material. If no service info is found and fetching it fails, we return None to
298indicate that the service info is unavailable.
299*/
300pub async fn get_course_material_service_info_by_exercise_type(
301    conn: &mut PgConnection,
302    exercise_type: &str,
303    fetch_service_info: impl Fn(Url) -> BoxFuture<'static, ModelResult<ExerciseServiceInfoApi>>,
304) -> ModelResult<Option<CourseMaterialExerciseServiceInfo>> {
305    match get_exercise_service_by_exercise_type(conn, exercise_type).await {
306        Ok(exercise_service) => {
307            let full_service_info = get_upsert_service_info_by_exercise_service(
308                conn,
309                &exercise_service,
310                fetch_service_info,
311            )
312            .await;
313            let service_info_option = match full_service_info {
314                Ok(o) => {
315                    // Need to convert relative url to absolute url because
316                    // otherwise the material won't be able to request the path
317                    // if the path is in a different domain
318                    let mut url =
319                        Url::parse(&exercise_service.public_url).map_err(|original_err| {
320                            ModelError::new(
321                                ModelErrorType::Generic,
322                                original_err.to_string(),
323                                Some(original_err.into()),
324                            )
325                        })?;
326                    url.set_path(&o.user_interface_iframe_path);
327                    url.set_query(None);
328                    url.set_fragment(None);
329
330                    Some(CourseMaterialExerciseServiceInfo {
331                        exercise_iframe_url: url.to_string(),
332                    })
333                }
334                _ => None,
335            };
336
337            Ok(service_info_option)
338        }
339        _ => Ok(None),
340    }
341}