1use url::Url;
2use utoipa::ToSchema;
3
4use crate::{
5 exercise_service_info::{ExerciseServiceInfo, get_all_exercise_services_by_type},
6 prelude::*,
7};
8
9#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
10
11pub struct ExerciseService {
12 pub id: Uuid,
13 pub created_at: DateTime<Utc>,
14 pub updated_at: DateTime<Utc>,
15 pub deleted_at: Option<DateTime<Utc>>,
16 pub name: String,
17 pub slug: String,
18 pub public_url: String,
19 pub internal_url: Option<String>,
21 pub max_reprocessing_submissions_at_once: i32,
22}
23
24#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
26
27pub struct ExerciseServiceIframeRenderingInfo {
28 pub id: Uuid,
29 pub name: String,
30 pub slug: String,
31 pub public_iframe_url: String,
32 pub has_custom_view: bool,
34}
35
36#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
37
38pub struct ExerciseServiceNewOrUpdate {
39 pub name: String,
40 pub slug: String,
41 pub public_url: String,
42 pub internal_url: Option<String>,
43 pub max_reprocessing_submissions_at_once: i32,
44}
45
46pub async fn get_exercise_service(
47 conn: &mut PgConnection,
48 id: Uuid,
49) -> ModelResult<ExerciseService> {
50 let res = sqlx::query_as!(
51 ExerciseService,
52 r#"
53SELECT *
54FROM exercise_services
55WHERE id = $1
56 "#,
57 id
58 )
59 .fetch_one(conn)
60 .await?;
61 Ok(res)
62}
63
64pub async fn update_exercise_service(
65 conn: &mut PgConnection,
66 id: Uuid,
67 exercise_service_update: &ExerciseServiceNewOrUpdate,
68) -> ModelResult<ExerciseService> {
69 let res = sqlx::query_as!(
70 ExerciseService,
71 r#"
72UPDATE exercise_services
73 SET name=$1, slug=$2, public_url=$3, internal_url=$4, max_reprocessing_submissions_at_once=$5
74WHERE id=$6
75 RETURNING *
76 "#,
77 exercise_service_update.name,
78 exercise_service_update.slug,
79 exercise_service_update.public_url,
80 exercise_service_update.internal_url,
81 exercise_service_update.max_reprocessing_submissions_at_once,
82 id
83 )
84 .fetch_one(conn)
85 .await?;
86 Ok(res)
87}
88
89pub async fn delete_exercise_service(
90 conn: &mut PgConnection,
91 id: Uuid,
92) -> ModelResult<ExerciseService> {
93 let deleted = sqlx::query_as!(
94 ExerciseService,
95 r#"
96UPDATE exercise_services
97 SET deleted_at = now()
98WHERE id = $1
99AND deleted_at IS NULL
100 RETURNING *
101 "#,
102 id
103 )
104 .fetch_one(conn)
105 .await?;
106 Ok(deleted)
107}
108
109pub async fn get_exercise_service_by_exercise_type(
110 conn: &mut PgConnection,
111 exercise_type: &str,
112) -> ModelResult<ExerciseService> {
113 let res = sqlx::query_as!(
114 ExerciseService,
115 r#"
116SELECT *
117FROM exercise_services
118WHERE slug = $1
119AND deleted_at IS NULL
120 "#,
121 exercise_type
122 )
123 .fetch_one(conn)
124 .await?;
125 Ok(res)
126}
127
128pub async fn get_exercise_service_internally_preferred_baseurl_by_exercise_type(
129 conn: &mut PgConnection,
130 exercise_type: &str,
131) -> ModelResult<Url> {
132 let exercise_service = get_exercise_service_by_exercise_type(conn, exercise_type).await?;
133 get_exercise_service_internally_preferred_baseurl(&exercise_service)
134}
135
136pub fn get_exercise_service_internally_preferred_baseurl(
137 exercise_service: &ExerciseService,
138) -> ModelResult<Url> {
139 let stored_url_str = exercise_service
140 .internal_url
141 .as_ref()
142 .unwrap_or(&exercise_service.public_url);
143 let mut url = Url::parse(stored_url_str).map_err(|original_error| {
144 ModelError::new(
145 ModelErrorType::Generic,
146 original_error.to_string(),
147 Some(original_error.into()),
148 )
149 })?;
150 url.set_path("");
153 Ok(url)
154}
155
156pub fn get_exercise_service_externally_preferred_baseurl(
157 exercise_service: &ExerciseService,
158) -> ModelResult<Url> {
159 let stored_url_str = &exercise_service.public_url;
160 let mut url = Url::parse(stored_url_str).map_err(|original_error| {
161 ModelError::new(
162 ModelErrorType::Generic,
163 original_error.to_string(),
164 Some(original_error.into()),
165 )
166 })?;
167 url.set_path("");
170 Ok(url)
171}
172
173pub async fn get_internal_grade_url(
177 exercise_service: &ExerciseService,
178 exercise_service_info: &ExerciseServiceInfo,
179) -> ModelResult<Url> {
180 let mut url = get_exercise_service_internally_preferred_baseurl(exercise_service)?;
181 url.set_path(&exercise_service_info.grade_endpoint_path);
182 Ok(url)
183}
184
185pub fn get_internal_public_spec_url(
189 exercise_service: &ExerciseService,
190 exercise_service_info: &ExerciseServiceInfo,
191) -> ModelResult<Url> {
192 let mut url = get_exercise_service_internally_preferred_baseurl(exercise_service)?;
193 url.set_path(&exercise_service_info.public_spec_endpoint_path);
194 Ok(url)
195}
196
197pub async fn get_native_client_capable_slugs(conn: &mut PgConnection) -> ModelResult<Vec<String>> {
205 let res = sqlx::query_scalar!(
206 r#"
207SELECT es.slug
208FROM exercise_services AS es
209 JOIN exercise_service_info AS esi ON esi.exercise_service_id = es.id
210WHERE es.deleted_at IS NULL
211 AND esi.supports_native_client
212"#
213 )
214 .fetch_all(conn)
215 .await?;
216 Ok(res)
217}
218
219pub fn get_model_solution_url(
220 exercise_service: &ExerciseService,
221 exercise_service_info: &ExerciseServiceInfo,
222) -> ModelResult<Url> {
223 let mut url = get_exercise_service_internally_preferred_baseurl(exercise_service)?;
224 url.set_path(&exercise_service_info.model_solution_spec_endpoint_path);
225 Ok(url)
226}
227
228pub async fn get_exercise_services(conn: &mut PgConnection) -> ModelResult<Vec<ExerciseService>> {
229 let res = sqlx::query_as!(
230 ExerciseService,
231 r#"
232SELECT *
233FROM exercise_services
234WHERE deleted_at IS NULL
235"#
236 )
237 .fetch_all(conn)
238 .await?;
239 Ok(res)
240}
241
242pub async fn get_all_exercise_services_iframe_rendering_infos(
243 conn: &mut PgConnection,
244) -> ModelResult<Vec<ExerciseServiceIframeRenderingInfo>> {
245 let services = get_exercise_services(conn).await?;
246 let service_infos = get_all_exercise_services_by_type(conn).await?;
247 let res = services
248 .into_iter()
249 .filter_map(|exercise_service| {
250 if let Some((_, service_info)) = service_infos.get(&exercise_service.slug) {
251 match get_exercise_service_externally_preferred_baseurl(&exercise_service) { Ok(mut url) => {
252 url.set_path(&service_info.user_interface_iframe_path);
253 Some(ExerciseServiceIframeRenderingInfo {
254 id: exercise_service.id,
255 name: exercise_service.name,
256 slug: exercise_service.slug,
257 public_iframe_url: url.to_string(),
258 has_custom_view: service_info.has_custom_view,
259 })
260 } _ => {
261 warn!(exercise_service_id = ?exercise_service.id, "Skipping exercise service from the list because it has an invalid base url");
262 None
263 }}
264
265 } else {
266 warn!(exercise_service_id = ?exercise_service.id, "Skipping exercise service from the list because it doesn't have a service info");
267 None
268 }
269 })
270 .collect::<Vec<_>>();
271 Ok(res)
272}
273
274pub async fn insert_exercise_service(
275 conn: &mut PgConnection,
276 exercise_service_update: &ExerciseServiceNewOrUpdate,
277) -> ModelResult<ExerciseService> {
278 let res = sqlx::query_as!(
279 ExerciseService,
280 r#"
281INSERT INTO exercise_services (
282 name,
283 slug,
284 public_url,
285 internal_url,
286 max_reprocessing_submissions_at_once
287 )
288VALUES ($1, $2, $3, $4, $5)
289RETURNING *
290 "#,
291 exercise_service_update.name,
292 exercise_service_update.slug,
293 exercise_service_update.public_url,
294 exercise_service_update.internal_url,
295 exercise_service_update.max_reprocessing_submissions_at_once
296 )
297 .fetch_one(conn)
298 .await?;
299 Ok(res)
300}
301
302#[cfg(test)]
303mod test {
304 use super::*;
305 use crate::exercise_service_info::{self, ExerciseServiceInfo, PathInfo};
306 use crate::test_helper::*;
307
308 async fn insert_service(
309 tx: &mut PgConnection,
310 slug: &str,
311 supports_native_client: bool,
312 ) -> (ExerciseService, ExerciseServiceInfo) {
313 let service = insert_exercise_service(
314 tx,
315 &ExerciseServiceNewOrUpdate {
316 name: slug.to_string(),
317 slug: slug.to_string(),
318 public_url: "https://example.com".to_string(),
319 internal_url: Some("http://internal.example.com".to_string()),
320 max_reprocessing_submissions_at_once: 1,
321 },
322 )
323 .await
324 .unwrap();
325 let info = exercise_service_info::insert(
326 tx,
327 &PathInfo {
328 exercise_service_id: service.id,
329 user_interface_iframe_path: "/iframe".to_string(),
330 grade_endpoint_path: "/grade".to_string(),
331 public_spec_endpoint_path: "/public-spec".to_string(),
332 model_solution_spec_endpoint_path: "/model-solution".to_string(),
333 has_custom_view: false,
334 supports_native_client,
335 produces_file_answers: false,
336 declares_spec_files: false,
337 },
338 )
339 .await
340 .unwrap();
341 (service, info)
342 }
343
344 #[tokio::test]
345 async fn only_services_declaring_native_client_support_are_native_client_capable() {
346 insert_data!(:tx);
347 insert_service(tx.as_mut(), "capable", true).await;
348 insert_service(tx.as_mut(), "not-capable", false).await;
349
350 let slugs = get_native_client_capable_slugs(tx.as_mut()).await.unwrap();
352 assert!(slugs.contains(&"capable".to_string()), "{slugs:?}");
353 assert!(!slugs.contains(&"not-capable".to_string()), "{slugs:?}");
354 tx.rollback().await;
355 }
356
357 #[tokio::test]
360 async fn declaring_native_client_support_in_service_info_makes_a_service_capable() {
361 insert_data!(:tx);
362 let (service, info) = insert_service(tx.as_mut(), "late-declarer", false).await;
363 assert!(
364 !get_native_client_capable_slugs(tx.as_mut())
365 .await
366 .unwrap()
367 .contains(&"late-declarer".to_string())
368 );
369
370 exercise_service_info::upsert_service_info(
371 tx.as_mut(),
372 service.id,
373 &exercise_service_info::ExerciseServiceInfoApi {
374 service_name: "late-declarer".to_string(),
375 user_interface_iframe_path: info.user_interface_iframe_path.clone(),
376 grade_endpoint_path: info.grade_endpoint_path.clone(),
377 public_spec_endpoint_path: info.public_spec_endpoint_path.clone(),
378 model_solution_spec_endpoint_path: info.model_solution_spec_endpoint_path.clone(),
379 has_custom_view: Some(false),
380 csv_export_definitions_endpoint_path: None,
381 csv_export_answers_endpoint_path: None,
382 supports_native_client: true,
383 produces_file_answers: false,
384 declares_spec_files: false,
385 },
386 )
387 .await
388 .unwrap();
389 assert!(
390 get_native_client_capable_slugs(tx.as_mut())
391 .await
392 .unwrap()
393 .contains(&"late-declarer".to_string())
394 );
395 tx.rollback().await;
396 }
397
398 #[tokio::test]
399 async fn a_deleted_service_is_not_native_client_capable() {
400 insert_data!(:tx);
401 let (service, _) = insert_service(tx.as_mut(), "deleted-capable", true).await;
402 delete_exercise_service(tx.as_mut(), service.id)
403 .await
404 .unwrap();
405 assert!(
406 !get_native_client_capable_slugs(tx.as_mut())
407 .await
408 .unwrap()
409 .contains(&"deleted-capable".to_string())
410 );
411 tx.rollback().await;
412 }
413
414 #[tokio::test]
415 async fn a_service_without_service_info_is_not_native_client_capable() {
416 insert_data!(:tx);
417 insert_exercise_service(
418 tx.as_mut(),
419 &ExerciseServiceNewOrUpdate {
420 name: "no-info".to_string(),
421 slug: "no-info".to_string(),
422 public_url: "https://example.com".to_string(),
423 internal_url: None,
424 max_reprocessing_submissions_at_once: 1,
425 },
426 )
427 .await
428 .unwrap();
429 assert!(
430 !get_native_client_capable_slugs(tx.as_mut())
431 .await
432 .unwrap()
433 .contains(&"no-info".to_string())
434 );
435 tx.rollback().await;
436 }
437}