headless_lms_models/
exercise_services.rs

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