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
98AND deleted_at IS NULL
99    RETURNING *
100        "#,
101        id
102    )
103    .fetch_one(conn)
104    .await?;
105    Ok(deleted)
106}
107
108pub async fn get_exercise_service_by_exercise_type(
109    conn: &mut PgConnection,
110    exercise_type: &str,
111) -> ModelResult<ExerciseService> {
112    let res = sqlx::query_as!(
113        ExerciseService,
114        r#"
115SELECT *
116FROM exercise_services
117WHERE slug = $1
118AND deleted_at IS NULL
119  "#,
120        exercise_type
121    )
122    .fetch_one(conn)
123    .await?;
124    Ok(res)
125}
126
127pub async fn get_exercise_service_internally_preferred_baseurl_by_exercise_type(
128    conn: &mut PgConnection,
129    exercise_type: &str,
130) -> ModelResult<Url> {
131    let exercise_service = get_exercise_service_by_exercise_type(conn, exercise_type).await?;
132    get_exercise_service_internally_preferred_baseurl(&exercise_service)
133}
134
135pub fn get_exercise_service_internally_preferred_baseurl(
136    exercise_service: &ExerciseService,
137) -> ModelResult<Url> {
138    let stored_url_str = exercise_service
139        .internal_url
140        .as_ref()
141        .unwrap_or(&exercise_service.public_url);
142    let mut url = Url::parse(stored_url_str).map_err(|original_error| {
143        ModelError::new(
144            ModelErrorType::Generic,
145            original_error.to_string(),
146            Some(original_error.into()),
147        )
148    })?;
149    // remove the path because all relative urls in service info assume
150    // that the base url prefix has no path
151    url.set_path("");
152    Ok(url)
153}
154
155pub fn get_exercise_service_externally_preferred_baseurl(
156    exercise_service: &ExerciseService,
157) -> ModelResult<Url> {
158    let stored_url_str = &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
172/**
173Returns a url that can be used to grade a submission for this exercise service.
174*/
175pub async fn get_internal_grade_url(
176    exercise_service: &ExerciseService,
177    exercise_service_info: &ExerciseServiceInfo,
178) -> ModelResult<Url> {
179    let mut url = get_exercise_service_internally_preferred_baseurl(exercise_service)?;
180    url.set_path(&exercise_service_info.grade_endpoint_path);
181    Ok(url)
182}
183
184/**
185Returns a url that can be used to generate a public version of a private spec.
186*/
187pub fn get_internal_public_spec_url(
188    exercise_service: &ExerciseService,
189    exercise_service_info: &ExerciseServiceInfo,
190) -> ModelResult<Url> {
191    let mut url = get_exercise_service_internally_preferred_baseurl(exercise_service)?;
192    url.set_path(&exercise_service_info.public_spec_endpoint_path);
193    Ok(url)
194}
195
196pub fn get_model_solution_url(
197    exercise_service: &ExerciseService,
198    exercise_service_info: &ExerciseServiceInfo,
199) -> ModelResult<Url> {
200    let mut url = get_exercise_service_internally_preferred_baseurl(exercise_service)?;
201    url.set_path(&exercise_service_info.model_solution_spec_endpoint_path);
202    Ok(url)
203}
204
205pub async fn get_exercise_services(conn: &mut PgConnection) -> ModelResult<Vec<ExerciseService>> {
206    let res = sqlx::query_as!(
207        ExerciseService,
208        r#"
209SELECT *
210FROM exercise_services
211WHERE deleted_at IS NULL
212"#
213    )
214    .fetch_all(conn)
215    .await?;
216    Ok(res)
217}
218
219pub async fn get_all_exercise_services_iframe_rendering_infos(
220    conn: &mut PgConnection,
221) -> ModelResult<Vec<ExerciseServiceIframeRenderingInfo>> {
222    let services = get_exercise_services(conn).await?;
223    let service_infos = get_all_exercise_services_by_type(conn).await?;
224    let res = services
225        .into_iter()
226        .filter_map(|exercise_service| {
227            if let Some((_, service_info)) = service_infos.get(&exercise_service.slug) {
228                match get_exercise_service_externally_preferred_baseurl(&exercise_service) { Ok(mut url) => {
229                    url.set_path(&service_info.user_interface_iframe_path);
230                    Some(ExerciseServiceIframeRenderingInfo {
231                        id: exercise_service.id,
232                        name: exercise_service.name,
233                        slug: exercise_service.slug,
234                        public_iframe_url: url.to_string(),
235                        has_custom_view: service_info.has_custom_view,
236                    })
237                } _ => {
238                    warn!(exercise_service_id = ?exercise_service.id, "Skipping exercise service from the list because it has an invalid base url");
239                    None
240                }}
241
242            } else {
243                warn!(exercise_service_id = ?exercise_service.id, "Skipping exercise service from the list because it doesn't have a service info");
244                None
245            }
246        })
247        .collect::<Vec<_>>();
248    Ok(res)
249}
250
251pub async fn insert_exercise_service(
252    conn: &mut PgConnection,
253    exercise_service_update: &ExerciseServiceNewOrUpdate,
254) -> ModelResult<ExerciseService> {
255    let res = sqlx::query_as!(
256        ExerciseService,
257        r#"
258INSERT INTO exercise_services (
259    name,
260    slug,
261    public_url,
262    internal_url,
263    max_reprocessing_submissions_at_once
264  )
265VALUES ($1, $2, $3, $4, $5)
266RETURNING *
267  "#,
268        exercise_service_update.name,
269        exercise_service_update.slug,
270        exercise_service_update.public_url,
271        exercise_service_update.internal_url,
272        exercise_service_update.max_reprocessing_submissions_at_once
273    )
274    .fetch_one(conn)
275    .await?;
276    Ok(res)
277}