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